feat(native): windows audio monitoring & recording (#12615)
fix AF-2692 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added comprehensive Windows support for audio and application capture, including real-time microphone usage detection, combined microphone and system audio recording, and application state monitoring. - The "meetings" setting is now enabled on Windows as well as macOS. - Conditional UI styling and attributes introduced for Windows environments in the Electron renderer. - **Bug Fixes** - Enhanced file path handling and validation for Windows in Electron file requests. - **Refactor** - Unified application info handling across platforms by consolidating types into a single `ApplicationInfo` structure. - Updated native module APIs by removing deprecated types, refining method signatures, and improving error messages. - Streamlined audio tapping APIs to use process IDs and consistent callback types. - **Documentation** - Added detailed documentation for the Windows-specific audio recording and microphone listener modules. - **Chores** - Updated development dependencies in multiple packages. - Reorganized and added platform-specific dependencies and configuration for Windows support. <!-- end of auto-generated comment: release notes by coderabbit.ai --> #### PR Dependency Tree * **PR #12615** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) --------- Co-authored-by: LongYinan <lynweklm@gmail.com>
This commit is contained in:
parent
c844786a7f
commit
899ffd1ad3
332
Cargo.lock
generated
332
Cargo.lock
generated
@ -77,8 +77,10 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"core-foundation",
|
||||
"coreaudio-rs",
|
||||
"coreaudio-rs 0.12.1",
|
||||
"cpal",
|
||||
"criterion2",
|
||||
"crossbeam-channel",
|
||||
"dispatch2",
|
||||
"libc",
|
||||
"napi",
|
||||
@ -91,6 +93,8 @@ dependencies = [
|
||||
"symphonia",
|
||||
"thiserror 2.0.12",
|
||||
"uuid",
|
||||
"windows 0.61.1",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -213,6 +217,28 @@ version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags 2.9.1",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android-tzdata"
|
||||
version = "0.1.1"
|
||||
@ -695,9 +721,17 @@ version = "1.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16595d3be041c03b09d08d0858631facccee9221e579704070e6e9e4915d3bc7"
|
||||
dependencies = [
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
@ -853,6 +887,16 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@ -979,6 +1023,17 @@ dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-rs"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-rs"
|
||||
version = "0.12.1"
|
||||
@ -999,6 +1054,29 @@ dependencies = [
|
||||
"bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.15.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-rs 0.11.3",
|
||||
"dasp_sample",
|
||||
"jni",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2",
|
||||
"ndk",
|
||||
"ndk-context",
|
||||
"oboe",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@ -1085,6 +1163,15 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
@ -1165,6 +1252,12 @@ dependencies = [
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@ -1820,7 +1913,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.61.2",
|
||||
"windows-core 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2082,6 +2175,38 @@ version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||
dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a"
|
||||
dependencies = [
|
||||
"getrandom 0.3.3",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.77"
|
||||
@ -2159,7 +2284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2264,6 +2389,15 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
@ -2370,9 +2504,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "napi"
|
||||
version = "3.0.0-beta.3"
|
||||
version = "3.0.0-beta.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a5c343e6e1fb57bf3ea3386638c4affb394ee932708128840a56aaac3d6a8ab"
|
||||
checksum = "c502f122fc89e92c6222810b3144411c6f945da5aa3b713ddfad3bdcae7c9bb4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.9.1",
|
||||
@ -2380,21 +2514,23 @@ dependencies = [
|
||||
"ctor",
|
||||
"napi-build",
|
||||
"napi-sys",
|
||||
"nohash-hasher",
|
||||
"rustc-hash 2.1.1",
|
||||
"serde",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-build"
|
||||
version = "2.2.0"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03acbfa4f156a32188bfa09b86dc11a431b5725253fc1fc6f6df5bed273382c4"
|
||||
checksum = "44e0e3177307063d3e7e55b7dd7b648cca9d7f46daa35422c0d98cc2bf48c2c1"
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive"
|
||||
version = "3.0.0-beta.3"
|
||||
version = "3.0.0-beta.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d23065ee795a4b1a8755fdf4a39c2a229679f01f923a8feea33f045d6d96cb"
|
||||
checksum = "fcf1e732a67e934b069d6d527251d6288753a36840572abe132a7aed9e77f0bc"
|
||||
dependencies = [
|
||||
"convert_case 0.8.0",
|
||||
"ctor",
|
||||
@ -2406,9 +2542,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive-backend"
|
||||
version = "2.0.0-beta.3"
|
||||
version = "2.0.0-beta.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "348aaac2c51b5d11cf90cf7670b470c7f4d1607d15c338efd4d3db361003e4f5"
|
||||
checksum = "462b775ba74791c98989fadc46c4bb2ec53016427be4d420d31c4bbaab34b308"
|
||||
dependencies = [
|
||||
"convert_case 0.8.0",
|
||||
"proc-macro2",
|
||||
@ -2419,13 +2555,42 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "napi-sys"
|
||||
version = "3.0.0-alpha.2"
|
||||
version = "3.0.0-alpha.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b443b980b2258dbaa31b99115e74da6c0866e537278309d566b4672a2f8df516"
|
||||
checksum = "c4401c63f866b42d673a8b213d5662c84a0701b0f6c3acff7e2b9fc439f1675d"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
"num_enum",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-context"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.5.0+25.2.9519653"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
|
||||
dependencies = [
|
||||
"jni-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
@ -2444,6 +2609,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
@ -2510,6 +2681,17 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
@ -2628,6 +2810,29 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
|
||||
dependencies = [
|
||||
"jni",
|
||||
"ndk",
|
||||
"ndk-context",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"oboe-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe-sys"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
@ -5003,6 +5208,19 @@ dependencies = [
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.100"
|
||||
@ -5116,7 +5334,7 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -5125,6 +5343,16 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||
dependencies = [
|
||||
"windows-core 0.54.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.57.0"
|
||||
@ -5157,6 +5385,16 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||
dependencies = [
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.57.0"
|
||||
@ -5280,6 +5518,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||
dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
@ -5307,6 +5554,21 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.42.2",
|
||||
"windows_aarch64_msvc 0.42.2",
|
||||
"windows_i686_gnu 0.42.2",
|
||||
"windows_i686_msvc 0.42.2",
|
||||
"windows_x86_64_gnu 0.42.2",
|
||||
"windows_x86_64_gnullvm 0.42.2",
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
@ -5347,6 +5609,12 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
@ -5359,6 +5627,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
@ -5371,6 +5645,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
@ -5389,6 +5669,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
@ -5401,6 +5687,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
@ -5413,6 +5705,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
@ -5425,6 +5723,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
|
15
Cargo.toml
15
Cargo.toml
@ -28,12 +28,14 @@ base64-simd = "0.8"
|
||||
bitvec = "1.0"
|
||||
block2 = "0.6"
|
||||
byteorder = "1.5"
|
||||
cpal = "0.15"
|
||||
chrono = "0.4"
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
core-foundation = "0.10"
|
||||
coreaudio-rs = "0.12"
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
criterion2 = { version = "3", default-features = false }
|
||||
crossbeam-channel = "0.5"
|
||||
dispatch2 = "0.3"
|
||||
docx-parser = { git = "https://github.com/toeverything/docx-parser" }
|
||||
dotenvy = "0.15"
|
||||
@ -97,6 +99,19 @@ uniffi = "0.29"
|
||||
url = { version = "2.5" }
|
||||
uuid = "1.8"
|
||||
v_htmlescape = "0.15"
|
||||
windows = { version = "0.61", features = [
|
||||
"Win32_Devices_FunctionDiscovery",
|
||||
"Win32_UI_Shell_PropertiesSystem",
|
||||
"Win32_Media_Audio",
|
||||
"Win32_System_Variant",
|
||||
"Win32_System_Com_StructuredStorage",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_ProcessStatus",
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
] }
|
||||
windows-core = { version = "0.61" }
|
||||
y-octo = { path = "./packages/common/y-octo/core" }
|
||||
y-sync = { version = "0.4" }
|
||||
yrs = "0.23.0"
|
||||
|
@ -32,7 +32,7 @@
|
||||
"build:debug": "napi build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "3.0.0-alpha.81",
|
||||
"@napi-rs/cli": "3.0.0-alpha.89",
|
||||
"lib0": "^0.2.99",
|
||||
"tiktoken": "^1.0.17",
|
||||
"tinybench": "^4.0.0",
|
||||
|
@ -21,7 +21,7 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "3.0.0-alpha.81",
|
||||
"@napi-rs/cli": "3.0.0-alpha.89",
|
||||
"@types/node": "^22.14.1",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"c8": "^10.1.3",
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
globalStyle('html', {
|
||||
@ -13,4 +14,9 @@ export const root = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
userSelect: 'none',
|
||||
selectors: {
|
||||
'&[data-is-windows]': {
|
||||
backgroundColor: cssVarV2('layer/background/primary'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -25,7 +25,7 @@ export function App() {
|
||||
<FrameworkRoot framework={frameworkProvider}>
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<div className={styles.root}>
|
||||
<div className={styles.root} data-is-windows={environment.isWindows}>
|
||||
{mode === 'recording' && <Recording />}
|
||||
</div>
|
||||
</I18nProvider>
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { join } from 'node:path';
|
||||
import path, { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { app, net, protocol, session } from 'electron';
|
||||
import cookieParser from 'set-cookie-parser';
|
||||
|
||||
import { resourcesPath } from '../shared/utils';
|
||||
import { isWindows, resourcesPath } from '../shared/utils';
|
||||
import { anotherHost, mainHost } from './constants';
|
||||
import { logger } from './logger';
|
||||
|
||||
@ -77,17 +78,23 @@ async function handleFileRequest(request: Request) {
|
||||
}
|
||||
} else {
|
||||
filepath = decodeURIComponent(urlObject.pathname);
|
||||
// on windows, the path could be start with '/'
|
||||
if (isWindows()) {
|
||||
filepath = path.resolve(filepath.replace(/^\//, ''));
|
||||
}
|
||||
// security check if the filepath is within app.getPath('sessionData')
|
||||
const sessionDataPath = app.getPath('sessionData');
|
||||
const tempPath = app.getPath('temp');
|
||||
const sessionDataPath = path
|
||||
.resolve(app.getPath('sessionData'))
|
||||
.toLowerCase();
|
||||
const tempPath = path.resolve(app.getPath('temp')).toLowerCase();
|
||||
if (
|
||||
!filepath.startsWith(sessionDataPath) &&
|
||||
!filepath.startsWith(tempPath)
|
||||
!filepath.toLowerCase().startsWith(sessionDataPath) &&
|
||||
!filepath.toLowerCase().startsWith(tempPath)
|
||||
) {
|
||||
throw new Error('Invalid filepath');
|
||||
}
|
||||
}
|
||||
return net.fetch('file://' + filepath, clonedRequest);
|
||||
return net.fetch(pathToFileURL(filepath).toString(), clonedRequest);
|
||||
}
|
||||
|
||||
export function registerProtocol() {
|
||||
|
@ -1,10 +1,11 @@
|
||||
/* oxlint-disable no-var-requires */
|
||||
import { execSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
// Should not load @affine/native for unsupported platforms
|
||||
import type { ShareableContent } from '@affine/native';
|
||||
import type { ShareableContent as ShareableContentType } from '@affine/native';
|
||||
import { app, systemPreferences } from 'electron';
|
||||
import fs from 'fs-extra';
|
||||
import { debounce } from 'lodash-es';
|
||||
@ -19,7 +20,7 @@ import {
|
||||
} from 'rxjs';
|
||||
import { filter, map, shareReplay } from 'rxjs/operators';
|
||||
|
||||
import { isMacOS, shallowEqual } from '../../shared/utils';
|
||||
import { isMacOS, isWindows, shallowEqual } from '../../shared/utils';
|
||||
import { beforeAppQuit } from '../cleanup';
|
||||
import { logger } from '../logger';
|
||||
import {
|
||||
@ -64,7 +65,7 @@ export const SAVED_RECORDINGS_DIR = path.join(
|
||||
'recordings'
|
||||
);
|
||||
|
||||
let shareableContent: ShareableContent | null = null;
|
||||
let shareableContent: ShareableContentType | null = null;
|
||||
|
||||
function cleanup() {
|
||||
shareableContent = null;
|
||||
@ -95,8 +96,10 @@ const recordings = new Map<number, Recording>();
|
||||
export const recordingStatus$ = recordingStateMachine.status$;
|
||||
|
||||
function createAppGroup(processGroupId: number): AppGroupInfo | undefined {
|
||||
const groupProcess =
|
||||
shareableContent?.applicationWithProcessId(processGroupId);
|
||||
// MUST require dynamically to avoid loading @affine/native for unsupported platforms
|
||||
const SC: typeof ShareableContentType =
|
||||
require('@affine/native').ShareableContent;
|
||||
const groupProcess = SC?.applicationWithProcessId(processGroupId);
|
||||
if (!groupProcess) {
|
||||
return;
|
||||
}
|
||||
@ -239,15 +242,30 @@ function setupNewRunningAppGroup() {
|
||||
);
|
||||
}
|
||||
|
||||
function getSanitizedAppId(bundleIdentifier?: string) {
|
||||
if (!bundleIdentifier) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
return isWindows()
|
||||
? createHash('sha256')
|
||||
.update(bundleIdentifier)
|
||||
.digest('hex')
|
||||
.substring(0, 8)
|
||||
: bundleIdentifier;
|
||||
}
|
||||
|
||||
export function createRecording(status: RecordingStatus) {
|
||||
let recording = recordings.get(status.id);
|
||||
if (recording) {
|
||||
return recording;
|
||||
}
|
||||
|
||||
const appId = getSanitizedAppId(status.appGroup?.bundleIdentifier);
|
||||
|
||||
const bufferedFilePath = path.join(
|
||||
SAVED_RECORDINGS_DIR,
|
||||
`${status.appGroup?.bundleIdentifier ?? 'unknown'}-${status.id}-${status.startTime}.raw`
|
||||
`${appId}-${status.id}-${status.startTime}.raw`
|
||||
);
|
||||
|
||||
fs.ensureDirSync(SAVED_RECORDINGS_DIR);
|
||||
@ -273,11 +291,12 @@ export function createRecording(status: RecordingStatus) {
|
||||
}
|
||||
|
||||
// MUST require dynamically to avoid loading @affine/native for unsupported platforms
|
||||
const ShareableContent = require('@affine/native').ShareableContent;
|
||||
const SC: typeof ShareableContentType =
|
||||
require('@affine/native').ShareableContent;
|
||||
|
||||
const stream = status.app
|
||||
? status.app.rawInstance.tapAudio(tapAudioSamples)
|
||||
: ShareableContent.tapGlobalAudio(null, tapAudioSamples);
|
||||
? SC.tapAudio(status.app.processId, tapAudioSamples)
|
||||
: SC.tapGlobalAudio(null, tapAudioSamples);
|
||||
|
||||
recording = {
|
||||
id: status.id,
|
||||
@ -379,15 +398,24 @@ function getAllApps(): TappableAppInfo[] {
|
||||
if (!shareableContent) {
|
||||
return [];
|
||||
}
|
||||
const apps = shareableContent.applications().map(app => {
|
||||
|
||||
// MUST require dynamically to avoid loading @affine/native for unsupported platforms
|
||||
const { ShareableContent } = require('@affine/native') as {
|
||||
ShareableContent: typeof ShareableContentType;
|
||||
};
|
||||
|
||||
const apps = ShareableContent.applications().map(app => {
|
||||
try {
|
||||
// Check if this process is actively using microphone/audio
|
||||
const isRunning = ShareableContent.isUsingMicrophone(app.processId);
|
||||
|
||||
return {
|
||||
rawInstance: app,
|
||||
info: app,
|
||||
processId: app.processId,
|
||||
processGroupId: app.processGroupId,
|
||||
bundleIdentifier: app.bundleIdentifier,
|
||||
name: app.name,
|
||||
isRunning: app.isRunning,
|
||||
isRunning,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('failed to get app info', error);
|
||||
@ -441,15 +469,15 @@ function setupMediaListeners() {
|
||||
|
||||
apps.forEach(app => {
|
||||
try {
|
||||
const tappableApp = app.rawInstance;
|
||||
const applicationInfo = app.info;
|
||||
_appStateSubscribers.push(
|
||||
ShareableContent.onAppStateChanged(tappableApp, () => {
|
||||
ShareableContent.onAppStateChanged(applicationInfo, () => {
|
||||
updateApplicationsPing$.next(Date.now());
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to convert app ${app.name} to TappableApplication`,
|
||||
`Failed to set up app state listener for ${app.name}`,
|
||||
error
|
||||
);
|
||||
}
|
||||
@ -668,15 +696,18 @@ export async function readyRecording(id: number, buffer: Buffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filepath = path.join(
|
||||
SAVED_RECORDINGS_DIR,
|
||||
`${recordingStatus.appGroup?.bundleIdentifier ?? 'unknown'}-${recordingStatus.id}-${recordingStatus.startTime}.opus`
|
||||
);
|
||||
const rawFilePath = String(recording.file.path);
|
||||
|
||||
const filepath = rawFilePath.replace('.raw', '.opus');
|
||||
|
||||
if (!filepath) {
|
||||
logger.error(`readyRecording: Recording ${id} has no filepath`);
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.writeFile(filepath, buffer);
|
||||
|
||||
// can safely remove the raw file now
|
||||
const rawFilePath = recording.file.path;
|
||||
logger.info('remove raw file', rawFilePath);
|
||||
if (rawFilePath) {
|
||||
try {
|
||||
@ -768,14 +799,24 @@ export const getMacOSVersion = () => {
|
||||
|
||||
// check if the system is MacOS and the version is >= 14.2
|
||||
export const checkRecordingAvailable = () => {
|
||||
if (!isMacOS()) {
|
||||
return false;
|
||||
if (isMacOS()) {
|
||||
const version = getMacOSVersion();
|
||||
return (version.major === 14 && version.minor >= 2) || version.major > 14;
|
||||
}
|
||||
const version = getMacOSVersion();
|
||||
return (version.major === 14 && version.minor >= 2) || version.major > 14;
|
||||
if (isWindows()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const checkMeetingPermissions = () => {
|
||||
if (isWindows()) {
|
||||
return {
|
||||
screen: true,
|
||||
microphone: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isMacOS()) {
|
||||
return undefined;
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import type { WriteStream } from 'node:fs';
|
||||
|
||||
import type { AudioCaptureSession, TappableApplication } from '@affine/native';
|
||||
import type { ApplicationInfo, AudioCaptureSession } from '@affine/native';
|
||||
|
||||
export interface TappableAppInfo {
|
||||
rawInstance: TappableApplication;
|
||||
info: ApplicationInfo;
|
||||
isRunning: boolean;
|
||||
processId: number;
|
||||
processGroupId: number;
|
||||
|
@ -77,6 +77,7 @@ abstract class PopupWindow {
|
||||
closable: false,
|
||||
alwaysOnTop: true,
|
||||
hiddenInMissionControl: true,
|
||||
skipTaskbar: true,
|
||||
movable: false,
|
||||
titleBarStyle: 'hidden',
|
||||
show: false, // hide by default,
|
||||
@ -243,6 +244,8 @@ export class PopupManager {
|
||||
return new NotificationPopupWindow() as PopupWindowTypeMap[T];
|
||||
case 'recording':
|
||||
return new RecordingPopupWindow() as PopupWindowTypeMap[T];
|
||||
default:
|
||||
throw new Error(`Unknown popup type: ${type}`);
|
||||
}
|
||||
})();
|
||||
|
||||
|
@ -95,7 +95,10 @@ export const useGeneralSettingList = (): GeneralSettingList => {
|
||||
});
|
||||
}
|
||||
|
||||
if (environment.isMacOs && BUILD_CONFIG.isElectron) {
|
||||
if (
|
||||
(environment.isMacOs || environment.isWindows) &&
|
||||
BUILD_CONFIG.isElectron
|
||||
) {
|
||||
settings.push({
|
||||
key: 'meetings',
|
||||
title: t['com.affine.settings.meetings'](),
|
||||
|
@ -295,28 +295,31 @@ const MeetingsSettingsMain = () => {
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingWrapper>
|
||||
<SettingWrapper
|
||||
title={t['com.affine.settings.meetings.privacy.header']()}
|
||||
>
|
||||
<PermissionSettingRow
|
||||
nameKey="com.affine.settings.meetings.privacy.screen-system-audio-recording"
|
||||
descriptionKey="com.affine.settings.meetings.privacy.screen-system-audio-recording.description"
|
||||
permissionSettingKey="com.affine.settings.meetings.privacy.screen-system-audio-recording.permission-setting"
|
||||
hasPermission={permissions?.screen || false}
|
||||
onOpenPermissionSetting={() =>
|
||||
handleOpenPermissionSetting('screen')
|
||||
}
|
||||
/>
|
||||
<PermissionSettingRow
|
||||
nameKey="com.affine.settings.meetings.privacy.microphone"
|
||||
descriptionKey="com.affine.settings.meetings.privacy.microphone.description"
|
||||
permissionSettingKey="com.affine.settings.meetings.privacy.microphone.permission-setting"
|
||||
hasPermission={permissions?.microphone || false}
|
||||
onOpenPermissionSetting={() =>
|
||||
handleOpenPermissionSetting('microphone')
|
||||
}
|
||||
/>
|
||||
</SettingWrapper>
|
||||
{environment.isMacOs && (
|
||||
<SettingWrapper
|
||||
title={t['com.affine.settings.meetings.privacy.header']()}
|
||||
>
|
||||
<PermissionSettingRow
|
||||
nameKey="com.affine.settings.meetings.privacy.screen-system-audio-recording"
|
||||
descriptionKey="com.affine.settings.meetings.privacy.screen-system-audio-recording.description"
|
||||
permissionSettingKey="com.affine.settings.meetings.privacy.screen-system-audio-recording.permission-setting"
|
||||
hasPermission={permissions?.screen || false}
|
||||
onOpenPermissionSetting={() =>
|
||||
handleOpenPermissionSetting('screen')
|
||||
}
|
||||
/>
|
||||
<PermissionSettingRow
|
||||
nameKey="com.affine.settings.meetings.privacy.microphone"
|
||||
descriptionKey="com.affine.settings.meetings.privacy.microphone.description"
|
||||
permissionSettingKey="com.affine.settings.meetings.privacy.microphone.permission-setting"
|
||||
hasPermission={permissions?.microphone || false}
|
||||
onOpenPermissionSetting={() =>
|
||||
handleOpenPermissionSetting('microphone')
|
||||
}
|
||||
/>
|
||||
</SettingWrapper>
|
||||
)}
|
||||
|
||||
<SettingWrapper>
|
||||
<SettingRow
|
||||
name={t['com.affine.settings.meetings.privacy.issues']()}
|
||||
|
@ -3,10 +3,9 @@ import { createServer } from 'node:http';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
type Application,
|
||||
type ApplicationInfo,
|
||||
type AudioCaptureSession,
|
||||
ShareableContent,
|
||||
type TappableApplication,
|
||||
} from '@affine/native';
|
||||
import type { FSWatcher } from 'chokidar';
|
||||
import chokidar from 'chokidar';
|
||||
@ -35,8 +34,8 @@ console.log(`📁 Ensuring recordings directory exists at ${RECORDING_DIR}`);
|
||||
|
||||
// Types
|
||||
interface Recording {
|
||||
app: TappableApplication | null;
|
||||
appGroup: Application | null;
|
||||
app: ApplicationInfo | null;
|
||||
appGroup: ApplicationInfo | null;
|
||||
buffers: Float32Array[];
|
||||
session: AudioCaptureSession;
|
||||
startTime: number;
|
||||
@ -66,7 +65,7 @@ interface RecordingMetadata {
|
||||
}
|
||||
|
||||
interface AppInfo {
|
||||
app?: TappableApplication;
|
||||
app?: ApplicationInfo;
|
||||
processId: number;
|
||||
processGroupId: number;
|
||||
bundleIdentifier: string;
|
||||
@ -151,6 +150,31 @@ const upload = multer({
|
||||
},
|
||||
});
|
||||
|
||||
// Helper functions to safely access properties from both ApplicationInfo and ApplicationInfo
|
||||
function getAppName(app: ApplicationInfo | null): string {
|
||||
if (!app) return 'Unknown App';
|
||||
return app.name ?? 'Unknown App';
|
||||
}
|
||||
|
||||
function getAppProcessId(app: ApplicationInfo | null): number {
|
||||
if (!app) return 0;
|
||||
return app.processId ?? 0;
|
||||
}
|
||||
|
||||
function getAppBundleIdentifier(app: ApplicationInfo | null): string {
|
||||
if (!app) return 'unknown';
|
||||
return app.bundleIdentifier ?? 'unknown';
|
||||
}
|
||||
|
||||
function getAppIcon(app: ApplicationInfo | null): Buffer | null {
|
||||
if (!app) return null;
|
||||
try {
|
||||
return app.icon ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Recording management
|
||||
async function saveRecording(
|
||||
recording: Recording,
|
||||
@ -176,9 +200,9 @@ async function saveRecording(
|
||||
if (recording.isGlobal) {
|
||||
console.log('💾 Saving global recording:');
|
||||
} else {
|
||||
const appName = app?.name ?? 'Unknown App';
|
||||
const processId = app?.processId ?? 0;
|
||||
const bundleId = app?.bundleIdentifier ?? 'unknown';
|
||||
const appName = getAppName(app);
|
||||
const processId = getAppProcessId(app);
|
||||
const bundleId = getAppBundleIdentifier(app);
|
||||
console.log(`💾 Saving recording for ${appName}:`);
|
||||
if (app) {
|
||||
console.log(`- Process ID: ${processId}`);
|
||||
@ -208,7 +232,7 @@ async function saveRecording(
|
||||
const timestamp = Date.now();
|
||||
const baseFilename = recording.isGlobal
|
||||
? `global-recording-${timestamp}`
|
||||
: `${app?.bundleIdentifier ?? 'unknown'}-${app?.processId ?? 0}-${timestamp}`;
|
||||
: `${getAppBundleIdentifier(app)}-${getAppProcessId(app)}-${timestamp}`;
|
||||
|
||||
// Sanitize the baseFilename to prevent path traversal
|
||||
const sanitizedFilename = baseFilename
|
||||
@ -249,18 +273,19 @@ async function saveRecording(
|
||||
console.log('✅ Transcription Wav file written successfully');
|
||||
|
||||
// Save app icon if available
|
||||
if (app?.icon) {
|
||||
const appIcon = getAppIcon(app);
|
||||
if (appIcon) {
|
||||
console.log(`📝 Writing app icon to ${iconFilename}`);
|
||||
await fs.writeFile(iconFilename, app.icon);
|
||||
await fs.writeFile(iconFilename, appIcon);
|
||||
console.log('✅ App icon written successfully');
|
||||
}
|
||||
|
||||
console.log(`📝 Writing metadata to ${metadataFilename}`);
|
||||
// Save metadata with the actual sample rate from the stream
|
||||
const metadata: RecordingMetadata = {
|
||||
appName: app?.name ?? 'Global Recording',
|
||||
bundleIdentifier: app?.bundleIdentifier ?? 'system.global',
|
||||
processId: app?.processId ?? -1,
|
||||
appName: getAppName(app),
|
||||
bundleIdentifier: getAppBundleIdentifier(app),
|
||||
processId: getAppProcessId(app),
|
||||
recordingStartTime: recording.startTime,
|
||||
recordingEndTime,
|
||||
recordingDuration,
|
||||
@ -283,8 +308,8 @@ async function saveRecording(
|
||||
function getRecordingStatus(): RecordingStatus[] {
|
||||
return Array.from(recordingMap.entries()).map(([processId, recording]) => ({
|
||||
processId,
|
||||
bundleIdentifier: recording.app?.bundleIdentifier ?? 'system.global',
|
||||
name: recording.app?.name ?? 'Global Recording',
|
||||
bundleIdentifier: getAppBundleIdentifier(recording.app),
|
||||
name: getAppName(recording.app),
|
||||
startTime: recording.startTime,
|
||||
duration: Date.now() - recording.startTime,
|
||||
}));
|
||||
@ -294,41 +319,53 @@ function emitRecordingStatus() {
|
||||
io.emit('apps:recording', { recordings: getRecordingStatus() });
|
||||
}
|
||||
|
||||
async function startRecording(app: TappableApplication) {
|
||||
if (recordingMap.has(app.processId)) {
|
||||
async function startRecording(app: ApplicationInfo) {
|
||||
const appProcessId = getAppProcessId(app);
|
||||
const appName = getAppName(app);
|
||||
const appBundleId = getAppBundleIdentifier(app);
|
||||
|
||||
if (recordingMap.has(appProcessId)) {
|
||||
console.log(
|
||||
`⚠️ Recording already in progress for ${app.name} (PID: ${app.processId})`
|
||||
`⚠️ Recording already in progress for ${appName} (PID: ${appProcessId})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`🎙️ Starting recording for ${appName} (Bundle: ${appBundleId}, PID: ${appProcessId})`
|
||||
);
|
||||
|
||||
const processGroupId = app.processGroupId;
|
||||
const rootApp =
|
||||
shareableContent.applicationWithProcessId(processGroupId) ||
|
||||
shareableContent.applicationWithProcessId(app.processId);
|
||||
ShareableContent.applicationWithProcessId(processGroupId) ||
|
||||
ShareableContent.applicationWithProcessId(app.processId);
|
||||
|
||||
if (!rootApp) {
|
||||
console.error(`❌ App group not found for ${app.name}`);
|
||||
console.error(`❌ App group not found for ${appName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`🎙️ Starting recording for ${rootApp.name} (PID: ${rootApp.processId})`
|
||||
`🎙️ Recording from ${rootApp.name} (PID: ${rootApp.processId})`
|
||||
);
|
||||
|
||||
const buffers: Float32Array[] = [];
|
||||
const session = app.tapAudio((err, samples) => {
|
||||
if (err) {
|
||||
console.error(`❌ Audio stream error for ${rootApp.name}:`, err);
|
||||
return;
|
||||
const session = ShareableContent.tapAudio(
|
||||
appProcessId,
|
||||
(err: any, samples: any) => {
|
||||
if (err) {
|
||||
console.error(`❌ Audio stream error for ${rootApp.name}:`, err);
|
||||
return;
|
||||
}
|
||||
const recording = recordingMap.get(appProcessId);
|
||||
if (recording && !recording.isWriting) {
|
||||
buffers.push(new Float32Array(samples));
|
||||
}
|
||||
}
|
||||
const recording = recordingMap.get(app.processId);
|
||||
if (recording && !recording.isWriting) {
|
||||
buffers.push(new Float32Array(samples));
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
recordingMap.set(app.processId, {
|
||||
recordingMap.set(appProcessId, {
|
||||
app,
|
||||
appGroup: rootApp,
|
||||
buffers,
|
||||
@ -340,7 +377,7 @@ async function startRecording(app: TappableApplication) {
|
||||
console.log(`✅ Recording started successfully for ${rootApp.name}`);
|
||||
emitRecordingStatus();
|
||||
} catch (error) {
|
||||
console.error(`❌ Error starting recording for ${app.name}:`, error);
|
||||
console.error(`❌ Error starting recording for ${appName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
@ -352,9 +389,10 @@ async function stopRecording(processId: number) {
|
||||
}
|
||||
|
||||
const app = recording.appGroup || recording.app;
|
||||
const appName =
|
||||
app?.name ?? (recording.isGlobal ? 'Global Recording' : 'Unknown App');
|
||||
const appPid = app?.processId ?? processId;
|
||||
const appName = recording.isGlobal
|
||||
? 'Global Recording'
|
||||
: getAppName(app) || 'Unknown App';
|
||||
const appPid = getAppProcessId(app);
|
||||
|
||||
console.log(`⏹️ Stopping recording for ${appName} (PID: ${appPid})`);
|
||||
console.log(
|
||||
@ -529,11 +567,15 @@ async function setupRecordingsWatcher() {
|
||||
}
|
||||
}
|
||||
|
||||
// Application management
|
||||
const shareableContent = new ShareableContent();
|
||||
|
||||
/**
|
||||
* Gets all applications and groups them by bundle identifier.
|
||||
* For apps with the same bundle ID (e.g., multiple processes of the same app),
|
||||
* only one representative is returned. The selection prioritizes:
|
||||
* 1. Running apps over stopped apps
|
||||
* 2. Lower process IDs (usually parent processes)
|
||||
*/
|
||||
async function getAllApps(): Promise<AppInfo[]> {
|
||||
const apps: (AppInfo | null)[] = shareableContent.applications().map(app => {
|
||||
const apps: (AppInfo | null)[] = ShareableContent.applications().map(app => {
|
||||
try {
|
||||
return {
|
||||
app,
|
||||
@ -541,7 +583,9 @@ async function getAllApps(): Promise<AppInfo[]> {
|
||||
processGroupId: app.processGroupId,
|
||||
bundleIdentifier: app.bundleIdentifier,
|
||||
name: app.name,
|
||||
isRunning: app.isRunning,
|
||||
get isRunning() {
|
||||
return ShareableContent.isUsingMicrophone(app.processId);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@ -557,37 +601,57 @@ async function getAllApps(): Promise<AppInfo[]> {
|
||||
);
|
||||
});
|
||||
|
||||
// Group apps by bundleIdentifier - only keep one representative per bundle ID
|
||||
const bundleGroups = new Map<string, AppInfo[]>();
|
||||
|
||||
// Group all apps by their bundle identifier
|
||||
for (const app of filteredApps) {
|
||||
if (filteredApps.some(a => a.processId === app.processGroupId)) {
|
||||
continue;
|
||||
const bundleId = app.bundleIdentifier;
|
||||
if (!bundleGroups.has(bundleId)) {
|
||||
bundleGroups.set(bundleId, []);
|
||||
}
|
||||
const appGroup = shareableContent.applicationWithProcessId(
|
||||
app.processGroupId
|
||||
);
|
||||
if (!appGroup) {
|
||||
continue;
|
||||
}
|
||||
filteredApps.push({
|
||||
processId: appGroup.processId,
|
||||
processGroupId: appGroup.processGroupId,
|
||||
bundleIdentifier: appGroup.bundleIdentifier,
|
||||
name: appGroup.name,
|
||||
isRunning: false,
|
||||
});
|
||||
bundleGroups.get(bundleId)?.push(app);
|
||||
}
|
||||
|
||||
// Stop recording if app is not listed
|
||||
console.log(`📦 Found ${bundleGroups.size} unique bundle identifiers`);
|
||||
|
||||
// For each bundle group, select the best representative
|
||||
const groupedApps: AppInfo[] = [];
|
||||
|
||||
for (const [_, appsInGroup] of bundleGroups) {
|
||||
if (appsInGroup.length === 1) {
|
||||
// Only one app with this bundle ID, use it directly
|
||||
groupedApps.push(appsInGroup[0]);
|
||||
} else {
|
||||
// Multiple apps with same bundle ID, choose the best representative
|
||||
|
||||
// Prefer running apps, then apps with lower process IDs (usually parent processes)
|
||||
const sortedApps = appsInGroup.sort((a, b) => {
|
||||
// First priority: running apps
|
||||
if (a.isRunning !== b.isRunning) {
|
||||
return a.isRunning ? -1 : 1;
|
||||
}
|
||||
// Second priority: lower process ID (usually parent process)
|
||||
return a.processId - b.processId;
|
||||
});
|
||||
|
||||
const representative = sortedApps[0];
|
||||
groupedApps.push(representative);
|
||||
}
|
||||
}
|
||||
|
||||
// Stop recording if app is not listed (check by process ID)
|
||||
await Promise.all(
|
||||
Array.from(recordingMap.keys()).map(async processId => {
|
||||
if (!filteredApps.some(a => a.processId === processId)) {
|
||||
if (!groupedApps.some(a => a.processId === processId)) {
|
||||
await stopRecording(processId);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
listenToAppStateChanges(filteredApps);
|
||||
listenToAppStateChanges(groupedApps);
|
||||
|
||||
return filteredApps;
|
||||
return groupedApps;
|
||||
}
|
||||
|
||||
function listenToAppStateChanges(apps: AppInfo[]) {
|
||||
@ -597,19 +661,27 @@ function listenToAppStateChanges(apps: AppInfo[]) {
|
||||
return { unsubscribe: () => {} };
|
||||
}
|
||||
|
||||
const appName = getAppName(app);
|
||||
const appProcessId = getAppProcessId(app);
|
||||
|
||||
const onAppStateChanged = () => {
|
||||
const currentIsRunning =
|
||||
ShareableContent.isUsingMicrophone(appProcessId);
|
||||
|
||||
console.log(
|
||||
`🔄 Application state changed: ${app.name} (PID: ${app.processId}) is now ${
|
||||
app.isRunning ? '▶️ running' : '⏹️ stopped'
|
||||
`🔄 Application state changed: ${appName} (PID: ${appProcessId}) is now ${
|
||||
currentIsRunning ? '▶️ running' : '⏹️ stopped'
|
||||
}`
|
||||
);
|
||||
|
||||
// Emit state change to all clients
|
||||
io.emit('apps:state-changed', {
|
||||
processId: app.processId,
|
||||
isRunning: app.isRunning,
|
||||
processId: appProcessId,
|
||||
isRunning: currentIsRunning,
|
||||
});
|
||||
|
||||
if (!app.isRunning) {
|
||||
stopRecording(app.processId).catch(error => {
|
||||
if (!currentIsRunning) {
|
||||
stopRecording(appProcessId).catch(error => {
|
||||
console.error('❌ Error stopping recording:', error);
|
||||
});
|
||||
}
|
||||
@ -621,7 +693,7 @@ function listenToAppStateChanges(apps: AppInfo[]) {
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to listen to app state changes for ${app?.name}:`,
|
||||
`Failed to listen to app state changes for ${app ? getAppName(app) : 'unknown app'}:`,
|
||||
error
|
||||
);
|
||||
return { unsubscribe: () => {} };
|
||||
@ -652,7 +724,8 @@ io.on('connection', async socket => {
|
||||
console.log(`📤 Sending ${files.length} saved recordings to new client`);
|
||||
socket.emit('apps:saved', { recordings: files });
|
||||
|
||||
listenToAppStateChanges(initialApps.map(app => app.app).filter(app => !!app));
|
||||
// Set up state listeners for the current apps
|
||||
listenToAppStateChanges(initialApps.filter(appInfo => appInfo.app != null));
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('🔌 Client disconnected');
|
||||
@ -667,6 +740,9 @@ ShareableContent.onApplicationListChanged(() => {
|
||||
const apps = await getAllApps();
|
||||
console.log(`📢 Broadcasting ${apps.length} applications to all clients`);
|
||||
io.emit('apps:all', { apps });
|
||||
|
||||
// Set up state listeners for the updated apps
|
||||
listenToAppStateChanges(apps.filter(appInfo => appInfo.app != null));
|
||||
} catch (error) {
|
||||
console.error('❌ Error handling application list change:', error);
|
||||
}
|
||||
@ -769,7 +845,7 @@ app.delete('/recordings/:foldername', rateLimiter, (async (
|
||||
app.get('/apps/:process_id/icon', (req, res) => {
|
||||
const processId = parseInt(req.params.process_id);
|
||||
try {
|
||||
const app = shareableContent.applicationWithProcessId(processId);
|
||||
const app = ShareableContent.applicationWithProcessId(processId);
|
||||
if (!app) {
|
||||
res.status(404).json({ error: 'App not found' });
|
||||
return;
|
||||
@ -786,7 +862,7 @@ app.get('/apps/:process_id/icon', (req, res) => {
|
||||
app.post('/apps/:process_id/record', async (req, res) => {
|
||||
const processId = parseInt(req.params.process_id);
|
||||
try {
|
||||
const app = shareableContent.tappableApplicationWithProcessId(processId);
|
||||
const app = ShareableContent.applicationWithProcessId(processId);
|
||||
if (!app) {
|
||||
res.status(404).json({ error: 'App not found' });
|
||||
return;
|
||||
|
133
packages/frontend/native/index.d.ts
vendored
133
packages/frontend/native/index.d.ts
vendored
@ -1,11 +1,12 @@
|
||||
/* auto-generated by NAPI-RS */
|
||||
/* eslint-disable */
|
||||
export declare class Application {
|
||||
constructor(processId: number)
|
||||
get processId(): number
|
||||
export declare class ApplicationInfo {
|
||||
processId: number
|
||||
name: string
|
||||
objectId: number
|
||||
constructor(processId: number, name: string, objectId: number)
|
||||
get processGroupId(): number
|
||||
get bundleIdentifier(): string
|
||||
get name(): string
|
||||
get icon(): Buffer
|
||||
}
|
||||
|
||||
@ -18,12 +19,30 @@ export declare class ApplicationStateChangedSubscriber {
|
||||
}
|
||||
|
||||
export declare class AudioCaptureSession {
|
||||
stop(): void
|
||||
get sampleRate(): number
|
||||
get channels(): number
|
||||
get actualSampleRate(): number
|
||||
stop(): void
|
||||
}
|
||||
|
||||
export declare class ShareableContent {
|
||||
static onApplicationListChanged(callback: ((err: Error | null, ) => void)): ApplicationListChangedSubscriber
|
||||
static onAppStateChanged(app: ApplicationInfo, callback: ((err: Error | null, ) => void)): ApplicationStateChangedSubscriber
|
||||
constructor()
|
||||
static applications(): Array<ApplicationInfo>
|
||||
static applicationWithProcessId(processId: number): ApplicationInfo | null
|
||||
static tapAudio(processId: number, audioStreamCallback: ((err: Error | null, arg: Float32Array) => void)): AudioCaptureSession
|
||||
static tapGlobalAudio(excludedProcesses: Array<ApplicationInfo> | undefined | null, audioStreamCallback: ((err: Error | null, arg: Float32Array) => void)): AudioCaptureSession
|
||||
static isUsingMicrophone(processId: number): boolean
|
||||
}
|
||||
|
||||
export declare function decodeAudio(buf: Uint8Array, destSampleRate?: number | undefined | null, filename?: string | undefined | null, signal?: AbortSignal | undefined | null): Promise<Float32Array>
|
||||
|
||||
/** Decode audio file into a Float32Array */
|
||||
export declare function decodeAudioSync(buf: Uint8Array, destSampleRate?: number | undefined | null, filename?: string | undefined | null): Float32Array
|
||||
export declare function mintChallengeResponse(resource: string, bits?: number | undefined | null): Promise<string>
|
||||
|
||||
export declare function verifyChallengeResponse(response: string, bits: number, resource: string): Promise<boolean>
|
||||
export declare class DocStorage {
|
||||
constructor(path: string)
|
||||
validate(): Promise<boolean>
|
||||
@ -64,21 +83,43 @@ export declare class DocStoragePool {
|
||||
getBlobUploadedAt(universalId: string, peer: string, blobId: string): Promise<Date | null>
|
||||
}
|
||||
|
||||
export declare class RecordingPermissions {
|
||||
audio: boolean
|
||||
screen: boolean
|
||||
export interface Blob {
|
||||
key: string
|
||||
data: Uint8Array
|
||||
mime: string
|
||||
size: number
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export declare class ShareableContent {
|
||||
static onApplicationListChanged(callback: ((err: Error | null, ) => void)): ApplicationListChangedSubscriber
|
||||
static onAppStateChanged(app: TappableApplication, callback: ((err: Error | null, ) => void)): ApplicationStateChangedSubscriber
|
||||
constructor()
|
||||
applications(): Array<TappableApplication>
|
||||
applicationWithProcessId(processId: number): Application | null
|
||||
tappableApplicationWithProcessId(processId: number): TappableApplication | null
|
||||
static tapGlobalAudio(excludedProcesses: Array<TappableApplication> | undefined | null, audioStreamCallback: ((err: Error | null, arg: Float32Array) => void)): AudioCaptureSession
|
||||
export interface DocClock {
|
||||
docId: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export interface DocRecord {
|
||||
docId: string
|
||||
bin: Uint8Array
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export interface DocUpdate {
|
||||
docId: string
|
||||
timestamp: Date
|
||||
bin: Uint8Array
|
||||
}
|
||||
|
||||
export interface ListedBlob {
|
||||
key: string
|
||||
size: number
|
||||
mime: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export interface SetBlob {
|
||||
key: string
|
||||
data: Uint8Array
|
||||
mime: string
|
||||
}
|
||||
export declare class SqliteConnection {
|
||||
constructor(path: string)
|
||||
connect(): Promise<void>
|
||||
@ -117,80 +158,22 @@ export declare class SqliteConnection {
|
||||
checkpoint(): Promise<void>
|
||||
}
|
||||
|
||||
export declare class TappableApplication {
|
||||
constructor(objectId: AudioObjectID)
|
||||
static fromApplication(app: Application, objectId: AudioObjectID): TappableApplication
|
||||
get processId(): number
|
||||
get processGroupId(): number
|
||||
get bundleIdentifier(): string
|
||||
get name(): string
|
||||
get objectId(): number
|
||||
get icon(): Buffer
|
||||
get isRunning(): boolean
|
||||
tapAudio(audioStreamCallback: ((err: Error | null, arg: Float32Array) => void)): AudioCaptureSession
|
||||
}
|
||||
|
||||
export interface Blob {
|
||||
key: string
|
||||
data: Uint8Array
|
||||
mime: string
|
||||
size: number
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export interface BlobRow {
|
||||
key: string
|
||||
data: Buffer
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export declare function decodeAudio(buf: Uint8Array, destSampleRate?: number | undefined | null, filename?: string | undefined | null, signal?: AbortSignal | undefined | null): Promise<Float32Array>
|
||||
|
||||
/** Decode audio file into a Float32Array */
|
||||
export declare function decodeAudioSync(buf: Uint8Array, destSampleRate?: number | undefined | null, filename?: string | undefined | null): Float32Array
|
||||
|
||||
export interface DocClock {
|
||||
docId: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export interface DocRecord {
|
||||
docId: string
|
||||
bin: Uint8Array
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export interface DocTimestampRow {
|
||||
docId?: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export interface DocUpdate {
|
||||
docId: string
|
||||
timestamp: Date
|
||||
bin: Uint8Array
|
||||
}
|
||||
|
||||
export interface InsertRow {
|
||||
docId?: string
|
||||
data: Uint8Array
|
||||
}
|
||||
|
||||
export interface ListedBlob {
|
||||
key: string
|
||||
size: number
|
||||
mime: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export declare function mintChallengeResponse(resource: string, bits?: number | undefined | null): Promise<string>
|
||||
|
||||
export interface SetBlob {
|
||||
key: string
|
||||
data: Uint8Array
|
||||
mime: string
|
||||
}
|
||||
|
||||
export interface UpdateRow {
|
||||
id: number
|
||||
timestamp: Date
|
||||
@ -205,5 +188,3 @@ export declare enum ValidationResult {
|
||||
GeneralError = 3,
|
||||
Valid = 4
|
||||
}
|
||||
|
||||
export declare function verifyChallengeResponse(response: string, bits: number, resource: string): Promise<boolean>
|
||||
|
@ -365,28 +365,27 @@ if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) {
|
||||
|
||||
if (!nativeBinding) {
|
||||
if (loadErrors.length > 0) {
|
||||
// TODO Link to documentation with potential fixes
|
||||
// - The package owner could build/publish bindings for this arch
|
||||
// - The user may need to bundle the correct files
|
||||
// - The user may need to re-install node_modules to get new packages
|
||||
throw new Error('Failed to load native binding', { cause: loadErrors })
|
||||
throw new Error(
|
||||
`Cannot find native binding. ` +
|
||||
`npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). ` +
|
||||
'Please try `npm i` again after removing both package-lock.json and node_modules directory.',
|
||||
{ cause: loadErrors }
|
||||
)
|
||||
}
|
||||
throw new Error(`Failed to load native binding`)
|
||||
}
|
||||
|
||||
module.exports = nativeBinding
|
||||
module.exports.Application = nativeBinding.Application
|
||||
module.exports.ApplicationInfo = nativeBinding.ApplicationInfo
|
||||
module.exports.ApplicationListChangedSubscriber = nativeBinding.ApplicationListChangedSubscriber
|
||||
module.exports.ApplicationStateChangedSubscriber = nativeBinding.ApplicationStateChangedSubscriber
|
||||
module.exports.AudioCaptureSession = nativeBinding.AudioCaptureSession
|
||||
module.exports.DocStorage = nativeBinding.DocStorage
|
||||
module.exports.DocStoragePool = nativeBinding.DocStoragePool
|
||||
module.exports.RecordingPermissions = nativeBinding.RecordingPermissions
|
||||
module.exports.ShareableContent = nativeBinding.ShareableContent
|
||||
module.exports.SqliteConnection = nativeBinding.SqliteConnection
|
||||
module.exports.TappableApplication = nativeBinding.TappableApplication
|
||||
module.exports.decodeAudio = nativeBinding.decodeAudio
|
||||
module.exports.decodeAudioSync = nativeBinding.decodeAudioSync
|
||||
module.exports.mintChallengeResponse = nativeBinding.mintChallengeResponse
|
||||
module.exports.ValidationResult = nativeBinding.ValidationResult
|
||||
module.exports.verifyChallengeResponse = nativeBinding.verifyChallengeResponse
|
||||
module.exports.DocStorage = nativeBinding.DocStorage
|
||||
module.exports.DocStoragePool = nativeBinding.DocStoragePool
|
||||
module.exports.SqliteConnection = nativeBinding.SqliteConnection
|
||||
module.exports.ValidationResult = nativeBinding.ValidationResult
|
||||
|
@ -11,11 +11,11 @@ harness = false
|
||||
name = "mix_audio_samples"
|
||||
|
||||
[dependencies]
|
||||
napi = { workspace = true, features = ["napi4"] }
|
||||
napi-derive = { workspace = true, features = ["type-def"] }
|
||||
rubato = { workspace = true }
|
||||
symphonia = { workspace = true, features = ["all", "opt-simd"] }
|
||||
thiserror = { workspace = true }
|
||||
napi = { workspace = true, features = ["napi4"] }
|
||||
napi-derive = { workspace = true, features = ["type-def"] }
|
||||
rubato = { workspace = true }
|
||||
symphonia = { workspace = true, features = ["all", "opt-simd"] }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
block2 = { workspace = true }
|
||||
@ -28,6 +28,12 @@ objc2-foundation = { workspace = true }
|
||||
screencapturekit = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v4"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
cpal = { workspace = true }
|
||||
crossbeam-channel = { workspace = true }
|
||||
windows = { workspace = true }
|
||||
windows-core = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion2 = { workspace = true }
|
||||
|
||||
|
@ -2,4 +2,10 @@
|
||||
pub mod macos;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) use macos::*;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod windows;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use windows::*;
|
||||
|
||||
pub mod audio_decoder;
|
||||
|
@ -22,7 +22,7 @@ use coreaudio::sys::{
|
||||
};
|
||||
use libc;
|
||||
use napi::{
|
||||
bindgen_prelude::{Buffer, Error, Float32Array, Result, Status},
|
||||
bindgen_prelude::{Buffer, Error, Result, Status},
|
||||
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
|
||||
};
|
||||
use napi_derive::napi;
|
||||
@ -90,59 +90,22 @@ static NSRUNNING_APPLICATION_CLASS: LazyLock<Option<&'static AnyClass>> =
|
||||
LazyLock::new(|| AnyClass::get(c"NSRunningApplication"));
|
||||
|
||||
#[napi]
|
||||
pub struct Application {
|
||||
pub(crate) process_id: i32,
|
||||
pub(crate) name: String,
|
||||
#[derive(Clone)]
|
||||
pub struct ApplicationInfo {
|
||||
pub process_id: i32,
|
||||
pub name: String,
|
||||
pub object_id: u32,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl Application {
|
||||
impl ApplicationInfo {
|
||||
#[napi(constructor)]
|
||||
pub fn new(process_id: i32) -> Result<Self> {
|
||||
// Default values for when we can't get information
|
||||
let mut app = Self {
|
||||
pub fn new(process_id: i32, name: String, object_id: u32) -> Self {
|
||||
Self {
|
||||
process_id,
|
||||
name: String::new(),
|
||||
};
|
||||
|
||||
// Try to populate fields using NSRunningApplication
|
||||
if process_id > 0 {
|
||||
// Get NSRunningApplication class
|
||||
if let Some(running_app_class) = NSRUNNING_APPLICATION_CLASS.as_ref() {
|
||||
// Get running application with PID
|
||||
let running_app: *mut AnyObject = unsafe {
|
||||
msg_send![
|
||||
*running_app_class,
|
||||
runningApplicationWithProcessIdentifier: process_id
|
||||
]
|
||||
};
|
||||
|
||||
if !running_app.is_null() {
|
||||
// Get name
|
||||
unsafe {
|
||||
let name_ptr: *mut NSString = msg_send![running_app, localizedName];
|
||||
if !name_ptr.is_null() {
|
||||
let length: usize = msg_send![name_ptr, length];
|
||||
let utf8_ptr: *const u8 = msg_send![name_ptr, UTF8String];
|
||||
|
||||
if !utf8_ptr.is_null() {
|
||||
let bytes = std::slice::from_raw_parts(utf8_ptr, length);
|
||||
if let Ok(s) = std::str::from_utf8(bytes) {
|
||||
app.name = s.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
name,
|
||||
object_id,
|
||||
}
|
||||
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
pub fn process_id(&self) -> i32 {
|
||||
self.process_id
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
@ -191,12 +154,18 @@ impl Application {
|
||||
}
|
||||
}
|
||||
|
||||
String::new()
|
||||
}
|
||||
// If not available, try to get from the audio process property
|
||||
if self.object_id > 0 {
|
||||
if let Ok(bundle_id) =
|
||||
get_process_property::<CFStringRef>(&self.object_id, kAudioProcessPropertyBundleID)
|
||||
{
|
||||
// Safely convert CFStringRef to Rust String
|
||||
let cf_string = unsafe { CFString::wrap_under_get_rule(bundle_id) };
|
||||
return cf_string.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
String::new()
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
@ -341,114 +310,6 @@ impl Application {
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct TappableApplication {
|
||||
pub(crate) app: Application,
|
||||
pub(crate) object_id: AudioObjectID,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl TappableApplication {
|
||||
#[napi(constructor)]
|
||||
pub fn new(object_id: AudioObjectID) -> Result<Self> {
|
||||
// Get process ID from object_id
|
||||
let process_id = get_process_property(&object_id, kAudioProcessPropertyPID).unwrap_or(-1);
|
||||
|
||||
// Create base Application
|
||||
let app = Application::new(process_id)?;
|
||||
|
||||
Ok(Self { app, object_id })
|
||||
}
|
||||
|
||||
#[napi(factory)]
|
||||
pub fn from_application(app: &Application, object_id: AudioObjectID) -> Self {
|
||||
Self {
|
||||
app: Application {
|
||||
process_id: app.process_id,
|
||||
name: app.name.clone(),
|
||||
},
|
||||
object_id,
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
pub fn process_id(&self) -> i32 {
|
||||
self.app.process_id
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
pub fn process_group_id(&self) -> i32 {
|
||||
self.app.process_group_id()
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
pub fn bundle_identifier(&self) -> String {
|
||||
// First try to get from the Application
|
||||
let app_bundle_id = self.app.bundle_identifier();
|
||||
if !app_bundle_id.is_empty() {
|
||||
return app_bundle_id;
|
||||
}
|
||||
|
||||
// If not available, try to get from the audio process property
|
||||
match get_process_property::<CFStringRef>(&self.object_id, kAudioProcessPropertyBundleID) {
|
||||
Ok(bundle_id) => {
|
||||
// Safely convert CFStringRef to Rust String
|
||||
let cf_string = unsafe { CFString::wrap_under_create_rule(bundle_id) };
|
||||
cf_string.to_string()
|
||||
}
|
||||
Err(_) => {
|
||||
// Return empty string if we couldn't get the bundle ID
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
pub fn name(&self) -> String {
|
||||
self.app.name.clone()
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
pub fn object_id(&self) -> u32 {
|
||||
self.object_id
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
pub fn icon(&self) -> Result<Buffer> {
|
||||
self.app.icon()
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
pub fn get_is_running(&self) -> Result<bool> {
|
||||
// Use catch_unwind to prevent any panics
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
match get_process_property(&self.object_id, kAudioProcessPropertyIsRunningInput) {
|
||||
Ok(is_running) => Ok(is_running),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
});
|
||||
|
||||
// Handle any panics
|
||||
match result {
|
||||
Ok(result) => result,
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn tap_audio(
|
||||
&self,
|
||||
audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, true>>,
|
||||
) -> Result<AudioCaptureSession> {
|
||||
// Use AggregateDeviceManager instead of AggregateDevice directly
|
||||
// This provides automatic default device change detection
|
||||
let mut device_manager = AggregateDeviceManager::new(self)?;
|
||||
device_manager.start_capture(audio_stream_callback)?;
|
||||
let boxed_manager = Box::new(device_manager);
|
||||
Ok(AudioCaptureSession::new(boxed_manager))
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct ApplicationListChangedSubscriber {
|
||||
listener_block: RcBlock<dyn Fn(u32, *mut c_void)>,
|
||||
@ -538,19 +399,14 @@ pub struct ShareableContent {
|
||||
_inner: SCShareableContent,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
#[derive(Default)]
|
||||
pub struct RecordingPermissions {
|
||||
pub audio: bool,
|
||||
pub screen: bool,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl ShareableContent {
|
||||
#[napi]
|
||||
pub fn on_application_list_changed(
|
||||
callback: Arc<ThreadsafeFunction<(), ()>>,
|
||||
callback: ThreadsafeFunction<(), ()>,
|
||||
) -> Result<ApplicationListChangedSubscriber> {
|
||||
let callback_arc = Arc::new(callback);
|
||||
let callback_clone = callback_arc.clone();
|
||||
let callback_block: RcBlock<dyn Fn(u32, *mut c_void)> =
|
||||
RcBlock::new(move |_in_number_addresses, _in_addresses: *mut c_void| {
|
||||
if let Err(err) = RUNNING_APPLICATIONS
|
||||
@ -565,9 +421,9 @@ impl ShareableContent {
|
||||
*running_applications = audio_process_list();
|
||||
})
|
||||
{
|
||||
callback.call(Err(err), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
callback_clone.call(Err(err), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
} else {
|
||||
callback.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
callback_clone.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
}
|
||||
});
|
||||
|
||||
@ -598,11 +454,12 @@ impl ShareableContent {
|
||||
|
||||
#[napi]
|
||||
pub fn on_app_state_changed(
|
||||
app: &TappableApplication,
|
||||
callback: Arc<ThreadsafeFunction<(), ()>>,
|
||||
app: &ApplicationInfo,
|
||||
callback: ThreadsafeFunction<(), ()>,
|
||||
) -> Result<ApplicationStateChangedSubscriber> {
|
||||
let id = Uuid::new_v4();
|
||||
let object_id = app.object_id;
|
||||
let callback_arc = Arc::new(callback);
|
||||
|
||||
let mut lock = APPLICATION_STATE_CHANGED_SUBSCRIBERS.write().map_err(|_| {
|
||||
Error::new(
|
||||
@ -612,7 +469,7 @@ impl ShareableContent {
|
||||
})?;
|
||||
|
||||
if let Some(subscribers) = lock.get_mut(&object_id) {
|
||||
subscribers.insert(id, callback);
|
||||
subscribers.insert(id, callback_arc.clone());
|
||||
} else {
|
||||
let list_change: RcBlock<dyn Fn(u32, *mut c_void)> =
|
||||
RcBlock::new(move |in_number_addresses, in_addresses: *mut c_void| {
|
||||
@ -659,7 +516,7 @@ impl ShareableContent {
|
||||
}
|
||||
let subscribers = {
|
||||
let mut map = HashMap::new();
|
||||
map.insert(id, callback);
|
||||
map.insert(id, callback_arc.clone());
|
||||
map
|
||||
};
|
||||
lock.insert(object_id, subscribers);
|
||||
@ -675,7 +532,7 @@ impl ShareableContent {
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn applications(&self) -> Result<Vec<TappableApplication>> {
|
||||
pub fn applications() -> Result<Vec<ApplicationInfo>> {
|
||||
let app_list = RUNNING_APPLICATIONS
|
||||
.read()
|
||||
.map_err(|_| {
|
||||
@ -687,13 +544,44 @@ impl ShareableContent {
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter_map(|id| {
|
||||
let tappable_app = match TappableApplication::new(*id) {
|
||||
Ok(app) => app,
|
||||
Err(_) => return None,
|
||||
};
|
||||
// Get process ID from object_id
|
||||
let process_id = get_process_property(id, kAudioProcessPropertyPID).unwrap_or(-1);
|
||||
|
||||
if !tappable_app.bundle_identifier().is_empty() {
|
||||
Some(tappable_app)
|
||||
if process_id <= 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Get application name using NSRunningApplication
|
||||
let mut name = String::new();
|
||||
if let Some(running_app_class) = NSRUNNING_APPLICATION_CLASS.as_ref() {
|
||||
let running_app: *mut AnyObject = unsafe {
|
||||
msg_send![
|
||||
*running_app_class,
|
||||
runningApplicationWithProcessIdentifier: process_id
|
||||
]
|
||||
};
|
||||
|
||||
if !running_app.is_null() {
|
||||
unsafe {
|
||||
let name_ptr: *mut NSString = msg_send![running_app, localizedName];
|
||||
if !name_ptr.is_null() {
|
||||
let length: usize = msg_send![name_ptr, length];
|
||||
let utf8_ptr: *const u8 = msg_send![name_ptr, UTF8String];
|
||||
|
||||
if !utf8_ptr.is_null() {
|
||||
let bytes = std::slice::from_raw_parts(utf8_ptr, length);
|
||||
if let Ok(s) = std::str::from_utf8(bytes) {
|
||||
name = s.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let app = ApplicationInfo::new(process_id, name, *id);
|
||||
if !app.bundle_identifier().is_empty() {
|
||||
Some(app)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@ -704,7 +592,13 @@ impl ShareableContent {
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn application_with_process_id(&self, process_id: u32) -> Option<Application> {
|
||||
pub fn application_with_process_id(process_id: u32) -> Option<ApplicationInfo> {
|
||||
// check if the process is tappable
|
||||
let tappable = ShareableContent::tappable_application_with_process_id(process_id);
|
||||
if let Some(tappable) = tappable {
|
||||
return Some(tappable);
|
||||
}
|
||||
|
||||
// Get NSRunningApplication class
|
||||
let running_app_class = NSRUNNING_APPLICATION_CLASS.as_ref()?;
|
||||
|
||||
@ -720,40 +614,107 @@ impl ShareableContent {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Create an Application directly
|
||||
Application::new(process_id as i32).ok()
|
||||
// Get application name
|
||||
let mut name = String::new();
|
||||
unsafe {
|
||||
let name_ptr: *mut NSString = msg_send![running_app, localizedName];
|
||||
if !name_ptr.is_null() {
|
||||
let length: usize = msg_send![name_ptr, length];
|
||||
let utf8_ptr: *const u8 = msg_send![name_ptr, UTF8String];
|
||||
|
||||
if !utf8_ptr.is_null() {
|
||||
let bytes = std::slice::from_raw_parts(utf8_ptr, length);
|
||||
if let Ok(s) = std::str::from_utf8(bytes) {
|
||||
name = s.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create an ApplicationInfo with the proper name and object_id 0 (since we
|
||||
// don't have audio object_id from process_id alone)
|
||||
Some(ApplicationInfo::new(process_id as i32, name, 0))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn tappable_application_with_process_id(
|
||||
&self,
|
||||
process_id: u32,
|
||||
) -> Option<TappableApplication> {
|
||||
pub fn tappable_application_with_process_id(process_id: u32) -> Option<ApplicationInfo> {
|
||||
// Find the TappableApplication with this process ID in the list of running
|
||||
// applications
|
||||
match self.applications() {
|
||||
match ShareableContent::applications() {
|
||||
Ok(apps) => {
|
||||
for app in apps {
|
||||
if app.process_id() == process_id as i32 {
|
||||
if app.process_id == process_id as i32 {
|
||||
return Some(app);
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't find a TappableApplication with this process ID, create a new
|
||||
// one with a default object_id of 0 (which won't be able to tap audio)
|
||||
match Application::new(process_id as i32) {
|
||||
Ok(app) => Some(TappableApplication::from_application(&app, 0)),
|
||||
Err(_) => None,
|
||||
}
|
||||
None
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn is_using_microphone(process_id: u32) -> Result<bool> {
|
||||
if process_id == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Find the audio object ID for this process
|
||||
if let Ok(app_list) = RUNNING_APPLICATIONS.read() {
|
||||
if let Ok(app_list) = app_list.as_ref() {
|
||||
for object_id in app_list {
|
||||
let pid = get_process_property(object_id, kAudioProcessPropertyPID).unwrap_or(-1);
|
||||
if pid == process_id as i32 {
|
||||
// Check if the process is actively using input (microphone)
|
||||
match get_process_property(object_id, kAudioProcessPropertyIsRunningInput) {
|
||||
Ok(is_running) => return Ok(is_running),
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn tap_audio(
|
||||
process_id: u32,
|
||||
audio_stream_callback: ThreadsafeFunction<napi::bindgen_prelude::Float32Array, ()>,
|
||||
) -> Result<AudioCaptureSession> {
|
||||
let app = ShareableContent::applications()?
|
||||
.into_iter()
|
||||
.find(|app| app.process_id == process_id as i32);
|
||||
|
||||
if let Some(app) = app {
|
||||
if app.object_id == 0 {
|
||||
return Err(Error::new(
|
||||
Status::GenericFailure,
|
||||
"Cannot tap audio: invalid object_id",
|
||||
));
|
||||
}
|
||||
|
||||
// Convert ThreadsafeFunction to Arc<ThreadsafeFunction>
|
||||
let callback_arc = Arc::new(audio_stream_callback);
|
||||
|
||||
// Use AggregateDeviceManager instead of AggregateDevice directly
|
||||
// This provides automatic default device change detection
|
||||
let mut device_manager = AggregateDeviceManager::new(&app)?;
|
||||
device_manager.start_capture(callback_arc)?;
|
||||
let boxed_manager = Box::new(device_manager);
|
||||
Ok(AudioCaptureSession::new(boxed_manager))
|
||||
} else {
|
||||
Err(Error::new(
|
||||
Status::GenericFailure,
|
||||
"Application not found or not available for audio tapping",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn tap_global_audio(
|
||||
excluded_processes: Option<Vec<&TappableApplication>>,
|
||||
audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, true>>,
|
||||
excluded_processes: Option<Vec<&ApplicationInfo>>,
|
||||
audio_stream_callback: ThreadsafeFunction<napi::bindgen_prelude::Float32Array, ()>,
|
||||
) -> Result<AudioCaptureSession> {
|
||||
let excluded_object_ids = excluded_processes
|
||||
.unwrap_or_default()
|
||||
@ -761,9 +722,12 @@ impl ShareableContent {
|
||||
.map(|app| app.object_id)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Convert ThreadsafeFunction to Arc<ThreadsafeFunction>
|
||||
let callback_arc = Arc::new(audio_stream_callback);
|
||||
|
||||
// Use the new AggregateDeviceManager for automatic device adaptation
|
||||
let mut device_manager = AggregateDeviceManager::new_global(&excluded_object_ids)?;
|
||||
device_manager.start_capture(audio_stream_callback)?;
|
||||
device_manager.start_capture(callback_arc)?;
|
||||
let boxed_manager = Box::new(device_manager);
|
||||
Ok(AudioCaptureSession::new(boxed_manager))
|
||||
}
|
||||
|
@ -24,9 +24,8 @@ use coreaudio::sys::{
|
||||
AudioObjectRemovePropertyListenerBlock, AudioTimeStamp, OSStatus,
|
||||
};
|
||||
use napi::{
|
||||
bindgen_prelude::Float32Array,
|
||||
bindgen_prelude::{Float32Array, Result, Status},
|
||||
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
|
||||
Result,
|
||||
};
|
||||
use napi_derive::napi;
|
||||
use objc2::runtime::AnyObject;
|
||||
@ -38,7 +37,7 @@ use crate::{
|
||||
device::get_device_uid,
|
||||
error::CoreAudioError,
|
||||
queue::create_audio_tap_queue,
|
||||
screen_capture_kit::TappableApplication,
|
||||
screen_capture_kit::ApplicationInfo,
|
||||
utils::{cfstring_from_bytes_with_nul, get_global_main_property},
|
||||
};
|
||||
|
||||
@ -69,7 +68,7 @@ pub struct AggregateDevice {
|
||||
}
|
||||
|
||||
impl AggregateDevice {
|
||||
pub fn new(app: &TappableApplication) -> Result<Self> {
|
||||
pub fn new(app: &ApplicationInfo) -> Result<Self> {
|
||||
let object_id = app.object_id;
|
||||
|
||||
let tap_description = CATapDescription::init_stereo_mixdown_of_processes(object_id)?;
|
||||
@ -242,7 +241,7 @@ impl AggregateDevice {
|
||||
/// Implementation for the AggregateDevice to start processing audio
|
||||
pub fn start(
|
||||
&mut self,
|
||||
audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, true>>,
|
||||
audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, Status, true>>,
|
||||
// Add original_audio_stats to ensure consistent target rate
|
||||
original_audio_stats: AudioStats,
|
||||
) -> Result<AudioTapStream> {
|
||||
@ -598,13 +597,13 @@ pub struct AggregateDeviceManager {
|
||||
app_id: Option<AudioObjectID>,
|
||||
excluded_processes: Vec<AudioObjectID>,
|
||||
active_stream: Option<Arc<std::sync::Mutex<Option<AudioTapStream>>>>,
|
||||
audio_callback: Option<Arc<ThreadsafeFunction<Float32Array, (), Float32Array, true>>>,
|
||||
audio_callback: Option<Arc<ThreadsafeFunction<Float32Array, (), Float32Array, Status, true>>>,
|
||||
original_audio_stats: Option<AudioStats>,
|
||||
}
|
||||
|
||||
impl AggregateDeviceManager {
|
||||
/// Creates a new AggregateDeviceManager for a specific application
|
||||
pub fn new(app: &TappableApplication) -> Result<Self> {
|
||||
pub fn new(app: &ApplicationInfo) -> Result<Self> {
|
||||
let device = AggregateDevice::new(app)?;
|
||||
|
||||
Ok(Self {
|
||||
@ -638,7 +637,7 @@ impl AggregateDeviceManager {
|
||||
/// This sets up the initial stream and listeners.
|
||||
pub fn start_capture(
|
||||
&mut self,
|
||||
audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, true>>,
|
||||
audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, Status, true>>,
|
||||
) -> Result<()> {
|
||||
// Store the callback for potential device switch later
|
||||
self.audio_callback = Some(audio_stream_callback.clone());
|
||||
@ -717,10 +716,12 @@ impl AggregateDeviceManager {
|
||||
};
|
||||
|
||||
// Create a new device with updated default devices
|
||||
let result: Result<AggregateDevice> = (|| {
|
||||
let result: Result<AggregateDevice> = {
|
||||
if is_app_specific {
|
||||
if let Some(id) = app_id {
|
||||
let app = TappableApplication::new(id)?;
|
||||
// For device change listener, we need to create a minimal ApplicationInfo
|
||||
// We don't have the name here, so we'll use an empty string
|
||||
let app = ApplicationInfo::new(id as i32, String::new(), id);
|
||||
AggregateDevice::new(&app)
|
||||
} else {
|
||||
Err(CoreAudioError::CreateProcessTapFailed(0).into())
|
||||
@ -728,7 +729,7 @@ impl AggregateDeviceManager {
|
||||
} else {
|
||||
AggregateDevice::create_global_tap_but_exclude_processes(&excluded_processes)
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
// If we successfully created a new device, stop the old stream and start a new
|
||||
// one
|
||||
|
86
packages/frontend/native/media_capture/src/windows/README.md
Normal file
86
packages/frontend/native/media_capture/src/windows/README.md
Normal file
@ -0,0 +1,86 @@
|
||||
# Windows Audio Recording
|
||||
|
||||
This module provides Windows-specific audio recording functionality using the Windows Audio Session API (WASAPI).
|
||||
|
||||
## Features
|
||||
|
||||
- **Microphone Activity Detection**: Monitor when applications are using the microphone
|
||||
- **Process Identification**: Identify which process is using the microphone
|
||||
- **Real-time Notifications**: Get callbacks when microphone usage starts/stops
|
||||
|
||||
## Usage
|
||||
|
||||
### MicrophoneListener
|
||||
|
||||
The `MicrophoneListener` class provides real-time monitoring of microphone usage:
|
||||
|
||||
```typescript
|
||||
import { MicrophoneListener } from '@affine/native';
|
||||
|
||||
const listener = new MicrophoneListener((isRunning: boolean, processName: string) => {
|
||||
console.log(`Microphone ${isRunning ? 'started' : 'stopped'} by ${processName}`);
|
||||
});
|
||||
|
||||
// Check current status
|
||||
console.log('Is microphone currently active:', listener.is_running());
|
||||
```
|
||||
|
||||
### Callback Parameters
|
||||
|
||||
The callback receives two parameters:
|
||||
|
||||
- `isRunning: boolean` - Whether the microphone is currently active
|
||||
- `processName: string` - Name of the process using the microphone
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Audio Session Monitoring
|
||||
|
||||
The implementation uses Windows Audio Session API to:
|
||||
|
||||
1. **Enumerate Audio Sessions**: Get all active audio sessions
|
||||
2. **Monitor Session State**: Track when sessions become active/inactive
|
||||
3. **Process Identification**: Map audio sessions to process names
|
||||
4. **Event Handling**: Provide real-time notifications
|
||||
|
||||
### COM Initialization
|
||||
|
||||
The module automatically initializes COM (Component Object Model) with `COINIT_MULTITHREADED` for proper Windows API interaction.
|
||||
|
||||
### Error Handling
|
||||
|
||||
All Windows API errors are wrapped in `WindowsAudioError` enum and converted to NAPI errors for JavaScript consumption.
|
||||
|
||||
## Cross-Platform Compatibility
|
||||
|
||||
This Windows implementation maintains API compatibility with the macOS version, providing the same JavaScript interface while using Windows-specific APIs underneath.
|
||||
|
||||
## Platform Requirements
|
||||
|
||||
- Windows 10 or later
|
||||
- Microphone access permissions
|
||||
- Audio devices available
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `windows` crate v0.61 with Audio and Process features
|
||||
- `windows-core` crate v0.61
|
||||
- `napi` and `napi-derive` for JavaScript bindings
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### Thread Safety
|
||||
|
||||
The implementation uses thread-safe callbacks to JavaScript with `ThreadsafeFunction<(bool, String), ()>` to ensure proper communication between the Windows audio session monitoring thread and the JavaScript runtime.
|
||||
|
||||
### Process Name Resolution
|
||||
|
||||
Process names are resolved using Windows APIs:
|
||||
|
||||
- `GetModuleFileNameExW` for full executable path
|
||||
- `GetProcessImageFileNameW` as fallback
|
||||
- Automatic extraction of filename from full path
|
||||
|
||||
### Session Filtering
|
||||
|
||||
The implementation automatically filters out system audio sessions (like `AudioSrv`) to focus on user applications.
|
@ -0,0 +1,400 @@
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
thread::JoinHandle,
|
||||
};
|
||||
|
||||
use cpal::{
|
||||
traits::{DeviceTrait, HostTrait, StreamTrait},
|
||||
SampleRate,
|
||||
};
|
||||
use crossbeam_channel::unbounded;
|
||||
use napi::{
|
||||
bindgen_prelude::{Float32Array, Result},
|
||||
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
|
||||
Error, Status,
|
||||
};
|
||||
use napi_derive::napi;
|
||||
use rubato::{FastFixedIn, PolynomialDegree, Resampler};
|
||||
|
||||
const RESAMPLER_INPUT_CHUNK: usize = 1024; // samples per channel
|
||||
const TARGET_FRAME_SIZE: usize = 1024; // frame size returned to JS (in mono samples)
|
||||
|
||||
struct BufferedResampler {
|
||||
resampler: FastFixedIn<f32>,
|
||||
channels: usize,
|
||||
fifo: Vec<Vec<f32>>, // per-channel queue
|
||||
initial_output_discarded: bool, // Flag to discard first output block (warm-up)
|
||||
}
|
||||
|
||||
impl BufferedResampler {
|
||||
fn new(from_sr: f64, to_sr: f64, channels: usize) -> Self {
|
||||
let ratio = to_sr / from_sr;
|
||||
let resampler = FastFixedIn::<f32>::new(
|
||||
ratio,
|
||||
1.0, // max_resample_ratio_relative (>= 1.0, fixed ratio)
|
||||
PolynomialDegree::Linear, // balance quality/perf
|
||||
RESAMPLER_INPUT_CHUNK,
|
||||
channels,
|
||||
)
|
||||
.expect("Failed to create FastFixedIn resampler");
|
||||
|
||||
Self {
|
||||
resampler,
|
||||
channels,
|
||||
fifo: vec![Vec::<f32>::new(); channels],
|
||||
initial_output_discarded: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Feed planar samples; returns interleaved output (may be empty)
|
||||
fn feed(&mut self, planar_in: &[Vec<f32>]) -> Vec<f32> {
|
||||
// Push incoming samples into fifo buffers
|
||||
for (ch, data) in planar_in.iter().enumerate() {
|
||||
if ch < self.fifo.len() {
|
||||
self.fifo[ch].extend_from_slice(data);
|
||||
}
|
||||
}
|
||||
|
||||
let mut interleaved_out = Vec::new();
|
||||
|
||||
while self.fifo[0].len() >= RESAMPLER_INPUT_CHUNK {
|
||||
// Take exactly RESAMPLER_INPUT_CHUNK per channel
|
||||
let mut chunk: Vec<Vec<f32>> = Vec::with_capacity(self.channels);
|
||||
for ch in 0..self.channels {
|
||||
let tail: Vec<f32> = self.fifo[ch].drain(..RESAMPLER_INPUT_CHUNK).collect();
|
||||
chunk.push(tail);
|
||||
}
|
||||
|
||||
if let Ok(out_blocks) = self.resampler.process(&chunk, None) {
|
||||
if !out_blocks.is_empty() && out_blocks.len() == self.channels {
|
||||
if !self.initial_output_discarded {
|
||||
self.initial_output_discarded = true;
|
||||
} else {
|
||||
let out_len = out_blocks[0].len();
|
||||
for i in 0..out_len {
|
||||
for ch in 0..self.channels {
|
||||
interleaved_out.push(out_blocks[ch][i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interleaved_out
|
||||
}
|
||||
}
|
||||
|
||||
// Thread-local cache for resamplers keyed by (from, to, channels)
|
||||
thread_local! {
|
||||
static RESAMPLER_CACHE: RefCell<HashMap<(u32, u32, usize), BufferedResampler>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
fn process_audio_with_resampler(
|
||||
samples: Vec<f32>,
|
||||
from_sample_rate: u32,
|
||||
to_sample_rate: u32,
|
||||
) -> Vec<f32> {
|
||||
if from_sample_rate == to_sample_rate {
|
||||
return samples;
|
||||
}
|
||||
|
||||
RESAMPLER_CACHE.with(|cache| {
|
||||
let mut map = cache.borrow_mut();
|
||||
let key = (from_sample_rate, to_sample_rate, 1usize); // mono resampler
|
||||
let resampler = map
|
||||
.entry(key)
|
||||
.or_insert_with(|| BufferedResampler::new(from_sample_rate as f64, to_sample_rate as f64, 1));
|
||||
resampler.feed(&[samples])
|
||||
})
|
||||
}
|
||||
|
||||
fn to_mono(frame: &[f32]) -> f32 {
|
||||
if frame.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let sum: f32 = frame.iter().filter(|s| s.is_finite()).copied().sum();
|
||||
let mono = if frame.len() == 1 {
|
||||
sum // already mono, no reduction needed
|
||||
} else {
|
||||
// For multi-channel, take the sum but don't divide by channel count
|
||||
// This preserves more energy while still avoiding simple doubling
|
||||
sum * 0.7 // slight reduction to prevent clipping, but preserve energy
|
||||
};
|
||||
mono.clamp(-1.0, 1.0)
|
||||
}
|
||||
|
||||
fn mix(a: &[f32], b: &[f32]) -> Vec<f32> {
|
||||
let min_len = a.len().min(b.len());
|
||||
if min_len == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
const MIC_GAIN: f32 = 3.0; // Higher gain for microphone input
|
||||
const LOOPBACK_GAIN: f32 = 1.5; // Moderate gain for loopback
|
||||
const OVERALL_GAIN: f32 = 1.2; // Final boost
|
||||
|
||||
a.iter()
|
||||
.take(min_len)
|
||||
.zip(b.iter().take(min_len))
|
||||
.map(|(x, y)| {
|
||||
let x_clean = if x.is_finite() { *x } else { 0.0 };
|
||||
let y_clean = if y.is_finite() { *y } else { 0.0 };
|
||||
|
||||
// Apply individual gains to mic (x) and loopback (y), then mix
|
||||
let mic_boosted = x_clean * MIC_GAIN;
|
||||
let loopback_boosted = y_clean * LOOPBACK_GAIN;
|
||||
let mixed = (mic_boosted + loopback_boosted) * OVERALL_GAIN;
|
||||
|
||||
// Soft limiting using tanh for more natural sound than hard clipping
|
||||
if mixed.abs() > 1.0 {
|
||||
mixed.signum() * (1.0 - (-mixed.abs()).exp())
|
||||
} else {
|
||||
mixed
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct AudioBuffer {
|
||||
data: Vec<f32>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct AudioCaptureSession {
|
||||
mic_stream: cpal::Stream,
|
||||
lb_stream: cpal::Stream,
|
||||
stopped: Arc<AtomicBool>,
|
||||
sample_rate: SampleRate,
|
||||
channels: u32,
|
||||
jh: Option<JoinHandle<()>>, // background mixing thread
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl AudioCaptureSession {
|
||||
#[napi(getter)]
|
||||
pub fn get_sample_rate(&self) -> f64 {
|
||||
self.sample_rate.0 as f64
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
pub fn get_channels(&self) -> u32 {
|
||||
self.channels
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
pub fn get_actual_sample_rate(&self) -> f64 {
|
||||
// For CPAL we always operate at the target rate which is sample_rate
|
||||
self.sample_rate.0 as f64
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn stop(&mut self) -> Result<()> {
|
||||
if self.stopped.load(Ordering::SeqCst) {
|
||||
return Ok(());
|
||||
}
|
||||
self
|
||||
.mic_stream
|
||||
.pause()
|
||||
.map_err(|e| Error::new(Status::GenericFailure, format!("{e}")))?;
|
||||
self
|
||||
.lb_stream
|
||||
.pause()
|
||||
.map_err(|e| Error::new(Status::GenericFailure, format!("{e}")))?;
|
||||
self.stopped.store(true, Ordering::SeqCst);
|
||||
if let Some(jh) = self.jh.take() {
|
||||
let _ = jh.join(); // ignore poison
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AudioCaptureSession {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.stop(); // Ensure cleanup even if JS forgets to call stop()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_recording(
|
||||
audio_buffer_callback: ThreadsafeFunction<Float32Array, ()>,
|
||||
) -> Result<AudioCaptureSession> {
|
||||
let available_hosts = cpal::available_hosts();
|
||||
let host_id = available_hosts
|
||||
.first()
|
||||
.ok_or_else(|| Error::new(Status::GenericFailure, "No CPAL hosts available"))?;
|
||||
|
||||
let host =
|
||||
cpal::host_from_id(*host_id).map_err(|e| Error::new(Status::GenericFailure, format!("{e}")))?;
|
||||
|
||||
let mic = host
|
||||
.default_input_device()
|
||||
.ok_or_else(|| Error::new(Status::GenericFailure, "No default input device"))?;
|
||||
let loopback_device = host
|
||||
.default_output_device()
|
||||
.ok_or_else(|| Error::new(Status::GenericFailure, "No default output/loopback device"))?;
|
||||
|
||||
let mic_config = mic
|
||||
.default_input_config()
|
||||
.map_err(|e| Error::new(Status::GenericFailure, format!("{e}")))?;
|
||||
let lb_config = loopback_device
|
||||
.default_output_config()
|
||||
.map_err(|e| Error::new(Status::GenericFailure, format!("{e}")))?;
|
||||
|
||||
let mic_sample_rate = mic_config.sample_rate();
|
||||
let lb_sample_rate = lb_config.sample_rate();
|
||||
let target_rate = SampleRate(mic_sample_rate.min(lb_sample_rate).0);
|
||||
|
||||
let mic_channels = mic_config.channels();
|
||||
let lb_channels = lb_config.channels();
|
||||
|
||||
// Convert supported configs to concrete StreamConfigs
|
||||
let mic_stream_config: cpal::StreamConfig = mic_config.clone().into();
|
||||
let lb_stream_config: cpal::StreamConfig = lb_config.clone().into();
|
||||
|
||||
let stopped = Arc::new(AtomicBool::new(false));
|
||||
|
||||
// Channels for passing raw buffers between callback and mixer thread
|
||||
let (tx_mic, rx_mic) = unbounded::<AudioBuffer>();
|
||||
let (tx_lb, rx_lb) = unbounded::<AudioBuffer>();
|
||||
|
||||
// Build microphone input stream
|
||||
let mic_stream = mic
|
||||
.build_input_stream(
|
||||
&mic_stream_config,
|
||||
move |data: &[f32], _| {
|
||||
let _ = tx_mic.send(AudioBuffer {
|
||||
data: data.to_vec(),
|
||||
});
|
||||
},
|
||||
|err| eprintln!("CPAL mic stream error: {err}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| Error::new(Status::GenericFailure, format!("build_input_stream: {e}")))?;
|
||||
|
||||
// Build loopback stream by creating input stream on output device (WASAPI
|
||||
// supports this)
|
||||
let lb_stream = loopback_device
|
||||
.build_input_stream(
|
||||
&lb_stream_config,
|
||||
move |data: &[f32], _| {
|
||||
let _ = tx_lb.send(AudioBuffer {
|
||||
data: data.to_vec(),
|
||||
});
|
||||
},
|
||||
|err| eprintln!("CPAL loopback stream error: {err}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| Error::new(Status::GenericFailure, format!("build_lb_stream: {e}")))?;
|
||||
|
||||
let stopped_flag = stopped.clone();
|
||||
|
||||
let jh = std::thread::spawn(move || {
|
||||
// Accumulators before and after resampling
|
||||
let mut pre_mic: Vec<f32> = Vec::new();
|
||||
let mut pre_lb: Vec<f32> = Vec::new();
|
||||
let mut post_mic: Vec<f32> = Vec::new();
|
||||
let mut post_lb: Vec<f32> = Vec::new();
|
||||
|
||||
while !stopped_flag.load(Ordering::SeqCst) {
|
||||
// Gather input from channels
|
||||
while let Ok(buf) = rx_mic.try_recv() {
|
||||
let mono_samples: Vec<f32> = if mic_channels == 1 {
|
||||
buf.data
|
||||
} else {
|
||||
buf
|
||||
.data
|
||||
.chunks(mic_channels as usize)
|
||||
.map(to_mono)
|
||||
.collect()
|
||||
};
|
||||
pre_mic.extend_from_slice(&mono_samples);
|
||||
}
|
||||
|
||||
while let Ok(buf) = rx_lb.try_recv() {
|
||||
let mono_samples: Vec<f32> = if lb_channels == 1 {
|
||||
buf.data
|
||||
} else {
|
||||
buf.data.chunks(lb_channels as usize).map(to_mono).collect()
|
||||
};
|
||||
pre_lb.extend_from_slice(&mono_samples);
|
||||
}
|
||||
|
||||
// Resample when enough samples are available
|
||||
while pre_mic.len() >= RESAMPLER_INPUT_CHUNK {
|
||||
let to_resample: Vec<f32> = pre_mic.drain(..RESAMPLER_INPUT_CHUNK).collect();
|
||||
let processed = process_audio_with_resampler(to_resample, mic_sample_rate.0, target_rate.0);
|
||||
if !processed.is_empty() {
|
||||
post_mic.extend_from_slice(&processed);
|
||||
}
|
||||
}
|
||||
|
||||
while pre_lb.len() >= RESAMPLER_INPUT_CHUNK {
|
||||
let to_resample: Vec<f32> = pre_lb.drain(..RESAMPLER_INPUT_CHUNK).collect();
|
||||
let processed = process_audio_with_resampler(to_resample, lb_sample_rate.0, target_rate.0);
|
||||
if !processed.is_empty() {
|
||||
post_lb.extend_from_slice(&processed);
|
||||
}
|
||||
}
|
||||
|
||||
// Mix when we have TARGET_FRAME_SIZE samples available from both
|
||||
while post_mic.len() >= TARGET_FRAME_SIZE && post_lb.len() >= TARGET_FRAME_SIZE {
|
||||
let mic_chunk: Vec<f32> = post_mic.drain(..TARGET_FRAME_SIZE).collect();
|
||||
let lb_chunk: Vec<f32> = post_lb.drain(..TARGET_FRAME_SIZE).collect();
|
||||
let mixed = mix(&mic_chunk, &lb_chunk);
|
||||
if !mixed.is_empty() {
|
||||
let _ = audio_buffer_callback.call(
|
||||
Ok(mixed.clone().into()),
|
||||
ThreadsafeFunctionCallMode::NonBlocking,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent unbounded growth – keep some slack
|
||||
const MAX_PRE: usize = RESAMPLER_INPUT_CHUNK * 10;
|
||||
if pre_mic.len() > MAX_PRE {
|
||||
pre_mic.drain(..pre_mic.len() - MAX_PRE);
|
||||
}
|
||||
if pre_lb.len() > MAX_PRE {
|
||||
pre_lb.drain(..pre_lb.len() - MAX_PRE);
|
||||
}
|
||||
|
||||
const MAX_POST: usize = TARGET_FRAME_SIZE * 10;
|
||||
if post_mic.len() > MAX_POST {
|
||||
post_mic.drain(..post_mic.len() - MAX_POST);
|
||||
}
|
||||
if post_lb.len() > MAX_POST {
|
||||
post_lb.drain(..post_lb.len() - MAX_POST);
|
||||
}
|
||||
|
||||
// Sleep if nothing to do
|
||||
if rx_mic.is_empty()
|
||||
&& rx_lb.is_empty()
|
||||
&& post_mic.len() < TARGET_FRAME_SIZE
|
||||
&& post_lb.len() < TARGET_FRAME_SIZE
|
||||
{
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mic_stream
|
||||
.play()
|
||||
.map_err(|e| Error::new(Status::GenericFailure, format!("{e}")))?;
|
||||
lb_stream
|
||||
.play()
|
||||
.map_err(|e| Error::new(Status::GenericFailure, format!("{e}")))?;
|
||||
|
||||
Ok(AudioCaptureSession {
|
||||
mic_stream,
|
||||
lb_stream,
|
||||
stopped,
|
||||
sample_rate: target_rate,
|
||||
channels: 1, // mono output
|
||||
jh: Some(jh),
|
||||
})
|
||||
}
|
39
packages/frontend/native/media_capture/src/windows/error.rs
Normal file
39
packages/frontend/native/media_capture/src/windows/error.rs
Normal file
@ -0,0 +1,39 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum WindowsAudioError {
|
||||
#[error("Failed to initialize COM: {0}")]
|
||||
ComInitializationFailed(String),
|
||||
#[error("Failed to create device enumerator: {0}")]
|
||||
DeviceEnumeratorCreationFailed(String),
|
||||
#[error("Failed to get default audio endpoint: {0}")]
|
||||
DefaultAudioEndpointFailed(String),
|
||||
#[error("Failed to activate audio session manager: {0}")]
|
||||
AudioSessionManagerActivationFailed(String),
|
||||
#[error("Failed to register session notification: {0}")]
|
||||
SessionNotificationRegistrationFailed(String),
|
||||
#[error("Failed to get session enumerator: {0}")]
|
||||
SessionEnumeratorFailed(String),
|
||||
#[error("Failed to get session count: {0}")]
|
||||
SessionCountFailed(String),
|
||||
#[error("Failed to get session: {0}")]
|
||||
GetSessionFailed(String),
|
||||
#[error("Failed to get process ID: {0}")]
|
||||
ProcessIdFailed(String),
|
||||
#[error("Failed to get session state: {0}")]
|
||||
SessionStateFailed(String),
|
||||
#[error("Failed to register audio session notification: {0}")]
|
||||
AudioSessionNotificationFailed(String),
|
||||
#[error("Failed to unregister audio session notification: {0}")]
|
||||
AudioSessionUnregisterFailed(String),
|
||||
#[error("Failed to open process: {0}")]
|
||||
ProcessOpenFailed(String),
|
||||
#[error("Failed to get process name: {0}")]
|
||||
ProcessNameFailed(String),
|
||||
}
|
||||
|
||||
impl From<WindowsAudioError> for napi::Error {
|
||||
fn from(value: WindowsAudioError) -> Self {
|
||||
napi::Error::new(napi::Status::GenericFailure, value.to_string())
|
||||
}
|
||||
}
|
@ -0,0 +1,645 @@
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
os::windows::ffi::OsStringExt,
|
||||
process,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
};
|
||||
|
||||
use napi::{
|
||||
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
|
||||
Result,
|
||||
};
|
||||
use windows::{
|
||||
core::Interface,
|
||||
Win32::{
|
||||
Foundation::CloseHandle,
|
||||
Media::Audio::{
|
||||
eCapture, eCommunications, eConsole, AudioSessionState, AudioSessionStateActive,
|
||||
IAudioSessionControl, IAudioSessionControl2, IAudioSessionEnumerator, IAudioSessionEvents,
|
||||
IAudioSessionEvents_Impl, IAudioSessionManager2, IAudioSessionNotification,
|
||||
IAudioSessionNotification_Impl, IMMDevice, IMMDeviceCollection, IMMDeviceEnumerator,
|
||||
MMDeviceEnumerator, DEVICE_STATE_ACTIVE,
|
||||
},
|
||||
System::{
|
||||
Com::{CoCreateInstance, CoInitializeEx, CLSCTX_ALL, COINIT_MULTITHREADED},
|
||||
ProcessStatus::{GetModuleFileNameExW, GetProcessImageFileNameW},
|
||||
Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ},
|
||||
},
|
||||
},
|
||||
};
|
||||
use windows_core::implement;
|
||||
|
||||
pub struct AudioProcess {
|
||||
pub process_name: String,
|
||||
pub process_id: u32,
|
||||
pub device_id: String,
|
||||
pub device_name: String,
|
||||
pub is_running: bool,
|
||||
}
|
||||
|
||||
pub struct AudioDevice {
|
||||
pub device_id: String,
|
||||
pub device_name: String,
|
||||
pub is_default_communications: bool,
|
||||
pub is_default_console: bool,
|
||||
pub has_active_sessions: bool,
|
||||
}
|
||||
|
||||
// Simple struct for callback data - not a NAPI object
|
||||
#[derive(Clone)]
|
||||
pub struct MicrophoneActivateCallback {
|
||||
pub is_running: bool,
|
||||
pub process_name: String,
|
||||
pub device_id: String,
|
||||
pub device_name: String,
|
||||
}
|
||||
|
||||
#[implement(IAudioSessionEvents)]
|
||||
struct SessionEvents {
|
||||
process_name: String,
|
||||
device_id: String,
|
||||
device_name: String,
|
||||
callback: Arc<ThreadsafeFunction<(bool, String, String, String)>>,
|
||||
ctrl: IAudioSessionControl,
|
||||
events_ref: Arc<Mutex<Option<IAudioSessionEvents>>>,
|
||||
is_running: Arc<AtomicBool>,
|
||||
active_sessions: Arc<AtomicUsize>,
|
||||
session_is_active: AtomicBool,
|
||||
}
|
||||
|
||||
impl IAudioSessionEvents_Impl for SessionEvents_Impl {
|
||||
fn OnChannelVolumeChanged(
|
||||
&self,
|
||||
_channelcount: u32,
|
||||
_newchannelvolumearray: *const f32,
|
||||
_changedchannel: u32,
|
||||
_eventcontext: *const windows_core::GUID,
|
||||
) -> windows_core::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn OnDisplayNameChanged(
|
||||
&self,
|
||||
_newdisplayname: &windows_core::PCWSTR,
|
||||
_eventcontext: *const windows_core::GUID,
|
||||
) -> windows_core::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn OnGroupingParamChanged(
|
||||
&self,
|
||||
_newgroupingparam: *const windows_core::GUID,
|
||||
_eventcontext: *const windows_core::GUID,
|
||||
) -> windows_core::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn OnIconPathChanged(
|
||||
&self,
|
||||
_newiconpath: &windows_core::PCWSTR,
|
||||
_eventcontext: *const windows_core::GUID,
|
||||
) -> windows_core::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn OnSessionDisconnected(
|
||||
&self,
|
||||
_disconnectreason: windows::Win32::Media::Audio::AudioSessionDisconnectReason,
|
||||
) -> windows_core::Result<()> {
|
||||
if let Some(events) = self.events_ref.lock().unwrap().take() {
|
||||
unsafe { self.ctrl.UnregisterAudioSessionNotification(&events)? };
|
||||
}
|
||||
|
||||
// If this session was active, decrement the global counter
|
||||
if self.session_is_active.swap(false, Ordering::SeqCst) {
|
||||
let prev = self.active_sessions.fetch_sub(1, Ordering::SeqCst);
|
||||
if prev == 1 {
|
||||
// Last active session ended
|
||||
self.is_running.store(false, Ordering::Relaxed);
|
||||
// Notify JS side that recording has stopped
|
||||
self.callback.call(
|
||||
Ok((
|
||||
false,
|
||||
self.process_name.clone(),
|
||||
self.device_id.clone(),
|
||||
self.device_name.clone(),
|
||||
)),
|
||||
ThreadsafeFunctionCallMode::NonBlocking,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn OnSimpleVolumeChanged(
|
||||
&self,
|
||||
_newvolume: f32,
|
||||
_newmute: windows_core::BOOL,
|
||||
_eventcontext: *const windows_core::GUID,
|
||||
) -> windows_core::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn OnStateChanged(&self, newstate: AudioSessionState) -> windows_core::Result<()> {
|
||||
// Determine the new recording state for this session
|
||||
let currently_recording = newstate == AudioSessionStateActive;
|
||||
|
||||
// Atomically swap the flag tracking this particular session
|
||||
let previously_recording = self
|
||||
.session_is_active
|
||||
.swap(currently_recording, Ordering::SeqCst);
|
||||
|
||||
// Update the global counter accordingly
|
||||
if !previously_recording && currently_recording {
|
||||
// Session started
|
||||
let prev = self.active_sessions.fetch_add(1, Ordering::SeqCst);
|
||||
if prev == 0 {
|
||||
// First active session across the whole system
|
||||
self.is_running.store(true, Ordering::Relaxed);
|
||||
}
|
||||
} else if previously_recording && !currently_recording {
|
||||
// Session stopped
|
||||
let prev = self.active_sessions.fetch_sub(1, Ordering::SeqCst);
|
||||
if prev == 1 {
|
||||
// Last active session just stopped
|
||||
self.is_running.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
let overall_is_running = self.active_sessions.load(Ordering::SeqCst) > 0;
|
||||
|
||||
// Notify JS side (non-blocking)
|
||||
self.callback.call(
|
||||
Ok((
|
||||
overall_is_running,
|
||||
self.process_name.clone(),
|
||||
self.device_id.clone(),
|
||||
self.device_name.clone(),
|
||||
)),
|
||||
ThreadsafeFunctionCallMode::NonBlocking,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[implement(IAudioSessionNotification)]
|
||||
struct SessionNotifier {
|
||||
_mgr: IAudioSessionManager2, // keep mgr alive
|
||||
device_id: String,
|
||||
device_name: String,
|
||||
ctrl: Mutex<Option<(IAudioSessionControl2, IAudioSessionEvents)>>, /* keep the ctrl2 and
|
||||
* events alive */
|
||||
callback: Arc<ThreadsafeFunction<(bool, String, String, String)>>,
|
||||
is_running: Arc<AtomicBool>, // Shared is_running flag
|
||||
active_sessions: Arc<AtomicUsize>, // Global counter of active sessions
|
||||
}
|
||||
|
||||
impl SessionNotifier {
|
||||
fn new(
|
||||
mgr: &IAudioSessionManager2,
|
||||
device_id: String,
|
||||
device_name: String,
|
||||
callback: Arc<ThreadsafeFunction<(bool, String, String, String)>>,
|
||||
is_running: Arc<AtomicBool>,
|
||||
active_sessions: Arc<AtomicUsize>,
|
||||
) -> Self {
|
||||
Self {
|
||||
_mgr: mgr.clone(),
|
||||
device_id,
|
||||
device_name,
|
||||
ctrl: Default::default(),
|
||||
callback,
|
||||
is_running,
|
||||
active_sessions,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_state(&self, ctrl: &IAudioSessionControl) -> windows_core::Result<()> {
|
||||
let ctrl2: IAudioSessionControl2 = ctrl.cast()?;
|
||||
let process_id = unsafe { ctrl2.GetProcessId()? };
|
||||
|
||||
// Skip current process to avoid self-detection
|
||||
if process_id == process::id() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let process_name = match get_process_name(process_id) {
|
||||
Some(n) => n,
|
||||
None => unsafe { ctrl2.GetDisplayName()?.to_string()? },
|
||||
};
|
||||
// Skip system-sounds session
|
||||
// The `IsSystemSoundsSession` always true for unknown reason
|
||||
if process_name.contains("AudioSrv") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Active ⇒ microphone is recording
|
||||
if unsafe { ctrl.GetState()? } == AudioSessionStateActive {
|
||||
let mut should_notify = false;
|
||||
if let Ok(mut optional_ctrl) = self.ctrl.lock() {
|
||||
// Increment the active session counter. If this was the first, flip is_running
|
||||
// to true.
|
||||
let prev = self.active_sessions.fetch_add(1, Ordering::SeqCst);
|
||||
if prev == 0 {
|
||||
self.is_running.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
let events_ref = Arc::new(Mutex::new(None));
|
||||
let events: IAudioSessionEvents = SessionEvents {
|
||||
callback: self.callback.clone(),
|
||||
process_name: process_name.clone(),
|
||||
device_id: self.device_id.clone(),
|
||||
device_name: self.device_name.clone(),
|
||||
events_ref: events_ref.clone(),
|
||||
ctrl: ctrl.clone(),
|
||||
is_running: self.is_running.clone(),
|
||||
active_sessions: self.active_sessions.clone(),
|
||||
session_is_active: AtomicBool::new(true),
|
||||
}
|
||||
.into();
|
||||
let mut events_mut_ref = events_ref.lock().unwrap();
|
||||
*events_mut_ref = Some(events.clone());
|
||||
unsafe { ctrl.RegisterAudioSessionNotification(&events)? };
|
||||
// keep the ctrl2 alive so that the notification can be called
|
||||
*optional_ctrl = Some((ctrl2, events));
|
||||
|
||||
should_notify = true;
|
||||
}
|
||||
|
||||
if should_notify {
|
||||
self.callback.call(
|
||||
Ok((
|
||||
true,
|
||||
process_name,
|
||||
self.device_id.clone(),
|
||||
self.device_name.clone(),
|
||||
)),
|
||||
ThreadsafeFunctionCallMode::NonBlocking,
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl IAudioSessionNotification_Impl for SessionNotifier_Impl {
|
||||
fn OnSessionCreated(
|
||||
&self,
|
||||
ctrl_ref: windows_core::Ref<'_, windows::Win32::Media::Audio::IAudioSessionControl>,
|
||||
) -> windows_core::Result<()> {
|
||||
let Some(ctrl) = ctrl_ref.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
self.refresh_state(ctrl)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_audio_device_status_callback(
|
||||
is_running: Arc<AtomicBool>,
|
||||
active_sessions: Arc<AtomicUsize>,
|
||||
callback: Arc<ThreadsafeFunction<(bool, String, String, String)>>,
|
||||
) -> windows_core::Result<Vec<IAudioSessionNotification>> {
|
||||
unsafe {
|
||||
let enumerator: IMMDeviceEnumerator = CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?;
|
||||
|
||||
// Get all active capture devices
|
||||
let device_collection: IMMDeviceCollection =
|
||||
enumerator.EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE)?;
|
||||
|
||||
let device_count = device_collection.GetCount()?;
|
||||
let mut session_notifiers = Vec::new();
|
||||
|
||||
for i in 0..device_count {
|
||||
let device: IMMDevice = device_collection.Item(i)?;
|
||||
|
||||
// Device identifiers
|
||||
let device_id_pwstr = device.GetId()?;
|
||||
let device_id = device_id_pwstr.to_string()?;
|
||||
let device_name = format!("Audio Device {}", i);
|
||||
|
||||
// Activate session manager for this device
|
||||
let mgr: IAudioSessionManager2 = device.Activate(CLSCTX_ALL, None)?;
|
||||
|
||||
// Create notifier for this device
|
||||
let session_notifier = SessionNotifier::new(
|
||||
&mgr,
|
||||
device_id.clone(),
|
||||
device_name.clone(),
|
||||
callback.clone(),
|
||||
is_running.clone(),
|
||||
active_sessions.clone(),
|
||||
);
|
||||
|
||||
// Enumerate existing sessions to update counters and state immediately
|
||||
let list: IAudioSessionEnumerator = mgr.GetSessionEnumerator()?;
|
||||
let sessions = list.GetCount()?;
|
||||
for idx in 0..sessions {
|
||||
let ctrl = list.GetSession(idx)?;
|
||||
session_notifier.refresh_state(&ctrl)?;
|
||||
}
|
||||
|
||||
let session_notifier_impl: IAudioSessionNotification = session_notifier.into();
|
||||
mgr.RegisterSessionNotification(&session_notifier_impl)?;
|
||||
|
||||
session_notifiers.push(session_notifier_impl);
|
||||
}
|
||||
|
||||
Ok(session_notifiers)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MicrophoneListener {
|
||||
_session_notifiers: Vec<IAudioSessionNotification>, // keep the session_notifiers alive
|
||||
is_running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl MicrophoneListener {
|
||||
pub fn new(callback: ThreadsafeFunction<(bool, String, String, String)>) -> Self {
|
||||
unsafe {
|
||||
if CoInitializeEx(None, COINIT_MULTITHREADED).is_err() {
|
||||
// If COM initialization fails, create a listener with empty notifiers
|
||||
return Self {
|
||||
is_running: Arc::new(AtomicBool::new(false)),
|
||||
_session_notifiers: Vec::new(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let is_running = Arc::new(AtomicBool::new(false));
|
||||
let active_sessions = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let session_notifiers = match register_audio_device_status_callback(
|
||||
is_running.clone(),
|
||||
active_sessions.clone(),
|
||||
Arc::new(callback),
|
||||
) {
|
||||
Ok(notifiers) => notifiers,
|
||||
Err(_) => {
|
||||
// If registration fails, create a listener with empty notifiers
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
is_running,
|
||||
_session_notifiers: session_notifiers,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.is_running.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
// Static method to check if a specific process is using microphone
|
||||
// This is used by TappableApplication::is_running()
|
||||
pub fn is_process_using_microphone(process_id: u32) -> bool {
|
||||
// Use the proven get_all_audio_processes logic
|
||||
match get_all_audio_processes() {
|
||||
Ok(processes) => processes
|
||||
.iter()
|
||||
.any(|p| p.process_id == process_id && p.is_running),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_mgr_audio_session_running_status(
|
||||
mgr: &IAudioSessionManager2,
|
||||
) -> windows_core::Result<(bool, String)> {
|
||||
let list: IAudioSessionEnumerator = unsafe { mgr.GetSessionEnumerator()? };
|
||||
let sessions = unsafe { list.GetCount()? };
|
||||
for idx in 0..sessions {
|
||||
let ctrl = unsafe { list.GetSession(idx)? };
|
||||
let ctrl2: IAudioSessionControl2 = ctrl.cast()?;
|
||||
let process_id = unsafe { ctrl2.GetProcessId()? };
|
||||
|
||||
// Skip current process to avoid self-detection
|
||||
if process_id == process::id() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let process_name = match get_process_name(process_id) {
|
||||
Some(n) => n,
|
||||
None => unsafe { ctrl2.GetDisplayName()?.to_string()? },
|
||||
};
|
||||
// Skip system-sounds session
|
||||
// The `IsSystemSoundsSession` always true for unknown reason
|
||||
if process_name.contains("AudioSrv") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Active ⇒ microphone is recording
|
||||
if unsafe { ctrl.GetState()? } == AudioSessionStateActive {
|
||||
return Ok((true, process_name));
|
||||
}
|
||||
}
|
||||
Ok((false, String::new()))
|
||||
}
|
||||
|
||||
fn get_process_name(pid: u32) -> Option<String> {
|
||||
unsafe {
|
||||
// Open process with required access rights
|
||||
let process_handle =
|
||||
OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid).ok()?;
|
||||
|
||||
// Allocate a buffer large enough to hold extended-length paths (up to ~32K
|
||||
// characters) instead of the legacy MAX_PATH (260) limit.
|
||||
let mut buffer: Vec<u16> = std::iter::repeat(0).take(32_768).collect();
|
||||
|
||||
// Try GetModuleFileNameExW first (gives full path with extension)
|
||||
let length = GetModuleFileNameExW(
|
||||
Some(process_handle),
|
||||
None, // NULL for the process executable
|
||||
&mut buffer,
|
||||
);
|
||||
|
||||
// If that fails, try GetProcessImageFileNameW
|
||||
let length = if length == 0 {
|
||||
GetProcessImageFileNameW(process_handle, &mut buffer)
|
||||
} else {
|
||||
length
|
||||
};
|
||||
|
||||
// Clean up
|
||||
CloseHandle(process_handle).ok()?;
|
||||
|
||||
if length == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Convert to OsString then to a regular String. Truncate buffer first.
|
||||
buffer.truncate(length as usize);
|
||||
let os_string = OsString::from_wide(&buffer);
|
||||
|
||||
// Extract the file name from the path
|
||||
let path_str = os_string.to_string_lossy().to_string();
|
||||
path_str.rsplit('\\').next().map(|s| s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_audio_processes() -> Result<Vec<AudioProcess>> {
|
||||
unsafe {
|
||||
// Try to initialize COM, but don't fail if it's already initialized
|
||||
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
|
||||
};
|
||||
|
||||
let result = get_all_audio_processes()
|
||||
.map_err(|err| napi::Error::new(napi::Status::GenericFailure, err.message()))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn list_audio_devices() -> Result<Vec<AudioDevice>> {
|
||||
unsafe {
|
||||
// Try to initialize COM, but don't fail if it's already initialized
|
||||
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
|
||||
};
|
||||
|
||||
let result = get_all_audio_devices()
|
||||
.map_err(|err| napi::Error::new(napi::Status::GenericFailure, err.message()))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn get_all_audio_processes() -> windows_core::Result<Vec<AudioProcess>> {
|
||||
unsafe {
|
||||
let enumerator: IMMDeviceEnumerator = CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?;
|
||||
|
||||
let device_collection: IMMDeviceCollection =
|
||||
enumerator.EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE)?;
|
||||
|
||||
let device_count = device_collection.GetCount()?;
|
||||
let mut all_processes = Vec::new();
|
||||
let current_pid = process::id();
|
||||
|
||||
for i in 0..device_count {
|
||||
let device: IMMDevice = device_collection.Item(i)?;
|
||||
|
||||
let device_id_pwstr = device.GetId()?;
|
||||
let device_id = device_id_pwstr.to_string()?;
|
||||
let device_name = format!("Audio Device {}", i);
|
||||
|
||||
let mgr: IAudioSessionManager2 = device.Activate(CLSCTX_ALL, None)?;
|
||||
let list: IAudioSessionEnumerator = mgr.GetSessionEnumerator()?;
|
||||
let sessions = list.GetCount()?;
|
||||
|
||||
for idx in 0..sessions {
|
||||
let ctrl = list.GetSession(idx)?;
|
||||
let ctrl2: IAudioSessionControl2 = ctrl.cast()?;
|
||||
let process_id = ctrl2.GetProcessId()?;
|
||||
|
||||
// Skip current process to avoid self-detection
|
||||
if process_id == current_pid {
|
||||
continue;
|
||||
}
|
||||
|
||||
let process_name = match get_process_name(process_id) {
|
||||
Some(n) => n,
|
||||
None => ctrl2.GetDisplayName()?.to_string()?,
|
||||
};
|
||||
|
||||
// Skip system-sounds session
|
||||
if process_name.contains("AudioSrv") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_running = ctrl.GetState()? == AudioSessionStateActive;
|
||||
|
||||
all_processes.push(AudioProcess {
|
||||
process_name,
|
||||
process_id,
|
||||
device_id: device_id.clone(),
|
||||
device_name: device_name.clone(),
|
||||
is_running,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_processes)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_all_audio_devices() -> windows_core::Result<Vec<AudioDevice>> {
|
||||
unsafe {
|
||||
let enumerator: IMMDeviceEnumerator = CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?;
|
||||
|
||||
let device_collection: IMMDeviceCollection =
|
||||
enumerator.EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE)?;
|
||||
|
||||
let device_count = device_collection.GetCount()?;
|
||||
let mut devices = Vec::new();
|
||||
|
||||
// Get default devices for comparison
|
||||
let default_comm_device_id = enumerator
|
||||
.GetDefaultAudioEndpoint(eCapture, eCommunications)
|
||||
.and_then(|d| d.GetId())
|
||||
.and_then(|id| Ok(id.to_string().unwrap_or_default()))
|
||||
.ok();
|
||||
let default_console_device_id = enumerator
|
||||
.GetDefaultAudioEndpoint(eCapture, eConsole)
|
||||
.and_then(|d| d.GetId())
|
||||
.and_then(|id| Ok(id.to_string().unwrap_or_default()))
|
||||
.ok();
|
||||
|
||||
for i in 0..device_count {
|
||||
let device: IMMDevice = device_collection.Item(i)?;
|
||||
|
||||
let device_id_pwstr = device.GetId()?;
|
||||
let device_id = device_id_pwstr.to_string()?;
|
||||
let device_name = format!("Audio Device {}", i);
|
||||
|
||||
let is_default_communications = default_comm_device_id.as_ref() == Some(&device_id);
|
||||
let is_default_console = default_console_device_id.as_ref() == Some(&device_id);
|
||||
|
||||
let mgr: IAudioSessionManager2 = device.Activate(CLSCTX_ALL, None)?;
|
||||
let (has_active_sessions, _) = get_mgr_audio_session_running_status(&mgr)?;
|
||||
|
||||
devices.push(AudioDevice {
|
||||
device_id,
|
||||
device_name,
|
||||
is_default_communications,
|
||||
is_default_console,
|
||||
has_active_sessions,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(devices)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_active_audio_processes() -> Result<Vec<AudioProcess>> {
|
||||
unsafe {
|
||||
// Try to initialize COM, but don't fail if it's already initialized
|
||||
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
|
||||
};
|
||||
|
||||
let result = get_all_audio_processes()
|
||||
.map_err(|err| napi::Error::new(napi::Status::GenericFailure, err.message()))?;
|
||||
|
||||
// Filter to only return active/running processes
|
||||
let active_processes = result.into_iter().filter(|p| p.is_running).collect();
|
||||
Ok(active_processes)
|
||||
}
|
||||
|
||||
pub fn is_process_actively_using_microphone(pid: u32) -> Result<bool> {
|
||||
unsafe {
|
||||
// Try to initialize COM, but don't fail if it's already initialized
|
||||
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
|
||||
};
|
||||
|
||||
let result = get_all_audio_processes()
|
||||
.map_err(|err| napi::Error::new(napi::Status::GenericFailure, err.message()))?;
|
||||
|
||||
// Check if the PID exists in the list of active processes
|
||||
let is_active = result
|
||||
.iter()
|
||||
.any(|process| process.process_id == pid && process.is_running);
|
||||
|
||||
Ok(is_active)
|
||||
}
|
17
packages/frontend/native/media_capture/src/windows/mod.rs
Normal file
17
packages/frontend/native/media_capture/src/windows/mod.rs
Normal file
@ -0,0 +1,17 @@
|
||||
pub mod audio_capture;
|
||||
pub(crate) mod error;
|
||||
pub mod microphone_listener;
|
||||
pub mod screen_capture_kit;
|
||||
|
||||
pub use audio_capture::*;
|
||||
pub use microphone_listener::*;
|
||||
pub use screen_capture_kit::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn test_windows_module_loads() {
|
||||
// Simple test to ensure the Windows module compiles and loads correctly
|
||||
assert!(true);
|
||||
}
|
||||
}
|
@ -0,0 +1,449 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
ffi::OsString,
|
||||
os::windows::ffi::OsStringExt,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU32, Ordering},
|
||||
Arc, LazyLock, RwLock,
|
||||
},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use napi::{
|
||||
bindgen_prelude::{Buffer, Error, Result, Status},
|
||||
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
|
||||
};
|
||||
use napi_derive::napi;
|
||||
// Windows API imports
|
||||
use windows::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; // HWND removed
|
||||
use windows::Win32::System::{
|
||||
Com::{CoInitializeEx, COINIT_MULTITHREADED},
|
||||
Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,
|
||||
},
|
||||
ProcessStatus::{GetModuleFileNameExW, GetProcessImageFileNameW},
|
||||
Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ},
|
||||
};
|
||||
|
||||
// Import the function from microphone_listener
|
||||
use crate::windows::microphone_listener::is_process_actively_using_microphone;
|
||||
|
||||
// Type alias to match macOS API
|
||||
pub type AudioObjectID = u32;
|
||||
|
||||
// Global storage for running applications (Windows equivalent of macOS audio
|
||||
// process list)
|
||||
static RUNNING_APPLICATIONS: LazyLock<RwLock<Vec<u32>>> =
|
||||
LazyLock::new(|| RwLock::new(get_running_processes()));
|
||||
|
||||
// Simple counter for generating unique handles
|
||||
static NEXT_HANDLE: AtomicU32 = AtomicU32::new(1);
|
||||
|
||||
// Global storage for active watchers
|
||||
static ACTIVE_APP_WATCHERS: LazyLock<
|
||||
RwLock<Vec<(u32, u32, Arc<ThreadsafeFunction<(), ()>>, Arc<AtomicBool>)>>,
|
||||
> = LazyLock::new(|| RwLock::new(Vec::new()));
|
||||
|
||||
static ACTIVE_LIST_WATCHERS: LazyLock<
|
||||
RwLock<Vec<(u32, Arc<ThreadsafeFunction<(), ()>>, Arc<AtomicBool>)>>,
|
||||
> = LazyLock::new(|| RwLock::new(Vec::new()));
|
||||
|
||||
// Plain struct for efficient transmission via napi-rs
|
||||
#[napi]
|
||||
#[derive(Clone)]
|
||||
pub struct ApplicationInfo {
|
||||
pub process_id: i32,
|
||||
pub name: String,
|
||||
pub object_id: u32,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl ApplicationInfo {
|
||||
#[napi(constructor)]
|
||||
pub fn new(process_id: i32, name: String, object_id: u32) -> Self {
|
||||
Self {
|
||||
process_id,
|
||||
name,
|
||||
object_id,
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
pub fn process_group_id(&self) -> i32 {
|
||||
// Windows doesn't have process groups like Unix, return the process ID
|
||||
self.process_id
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
pub fn bundle_identifier(&self) -> String {
|
||||
// For Windows, return the fully-qualified path to the .exe on disk
|
||||
let path = get_process_executable_path(self.process_id as u32).unwrap_or_default();
|
||||
// Escape invalid filename characters for Windows
|
||||
escape_filename(&path)
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
pub fn icon(&self) -> Buffer {
|
||||
// For now, return empty buffer. In a full implementation, you would extract
|
||||
// the icon from the executable file using Windows APIs
|
||||
Buffer::from(Vec::<u8>::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct ApplicationListChangedSubscriber {
|
||||
handle: u32,
|
||||
// We'll store the callback and manage it through a background thread
|
||||
_callback: Arc<ThreadsafeFunction<(), ()>>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl ApplicationListChangedSubscriber {
|
||||
#[napi]
|
||||
pub fn unsubscribe(&self) -> Result<()> {
|
||||
if let Ok(mut watchers) = ACTIVE_LIST_WATCHERS.write() {
|
||||
if let Some(pos) = watchers
|
||||
.iter()
|
||||
.position(|(handle, _, _)| *handle == self.handle)
|
||||
{
|
||||
let (_, _, should_stop) = &watchers[pos];
|
||||
should_stop.store(true, Ordering::Relaxed);
|
||||
watchers.remove(pos);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct ApplicationStateChangedSubscriber {
|
||||
handle: u32,
|
||||
process_id: u32,
|
||||
_callback: Arc<ThreadsafeFunction<(), ()>>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl ApplicationStateChangedSubscriber {
|
||||
pub fn process_id(&self) -> u32 {
|
||||
self.process_id
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn unsubscribe(&self) {
|
||||
if let Ok(mut watchers) = ACTIVE_APP_WATCHERS.write() {
|
||||
if let Some(pos) = watchers
|
||||
.iter()
|
||||
.position(|(handle, _, _, _)| *handle == self.handle)
|
||||
{
|
||||
let (_, _, _, should_stop) = &watchers[pos];
|
||||
should_stop.store(true, Ordering::Relaxed);
|
||||
watchers.remove(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct ShareableContent {
|
||||
// Windows doesn't need an inner SCShareableContent equivalent
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl ShareableContent {
|
||||
#[napi]
|
||||
pub fn on_application_list_changed(
|
||||
callback: ThreadsafeFunction<(), ()>,
|
||||
) -> Result<ApplicationListChangedSubscriber> {
|
||||
let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
|
||||
let callback_arc = Arc::new(callback);
|
||||
|
||||
// Start monitoring for application list changes
|
||||
start_list_monitoring(handle, callback_arc.clone());
|
||||
|
||||
Ok(ApplicationListChangedSubscriber {
|
||||
handle,
|
||||
_callback: callback_arc,
|
||||
})
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn on_app_state_changed(
|
||||
app: &ApplicationInfo,
|
||||
callback: ThreadsafeFunction<(), ()>,
|
||||
) -> Result<ApplicationStateChangedSubscriber> {
|
||||
let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
|
||||
let process_id = app.process_id as u32;
|
||||
let callback_arc = Arc::new(callback);
|
||||
|
||||
// Start monitoring for this specific process's microphone state
|
||||
start_process_monitoring(handle, process_id, callback_arc.clone());
|
||||
|
||||
Ok(ApplicationStateChangedSubscriber {
|
||||
handle,
|
||||
process_id,
|
||||
_callback: callback_arc,
|
||||
})
|
||||
}
|
||||
|
||||
#[napi(constructor)]
|
||||
pub fn new() -> Self {
|
||||
unsafe {
|
||||
CoInitializeEx(None, COINIT_MULTITHREADED)
|
||||
.ok()
|
||||
.unwrap_or_else(|_| {
|
||||
// COM initialization failed, but we can't return an error from
|
||||
// constructor This is typically not fatal as COM might
|
||||
// already be initialized
|
||||
});
|
||||
}
|
||||
Self {}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn applications() -> Result<Vec<ApplicationInfo>> {
|
||||
let processes = RUNNING_APPLICATIONS.read().map_err(|_| {
|
||||
Error::new(
|
||||
Status::GenericFailure,
|
||||
"Failed to read running applications",
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut apps = Vec::new();
|
||||
for &process_id in processes.iter() {
|
||||
let name = get_process_name(process_id).unwrap_or_else(|| format!("Process {}", process_id));
|
||||
if !name.is_empty() && name != format!("Process {}", process_id) {
|
||||
let app_info = ApplicationInfo::new(process_id as i32, name, process_id);
|
||||
apps.push(app_info);
|
||||
}
|
||||
}
|
||||
Ok(apps)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn application_with_process_id(process_id: u32) -> Option<ApplicationInfo> {
|
||||
if is_process_running(process_id) {
|
||||
let name = get_process_name(process_id).unwrap_or_else(|| format!("Process {}", process_id));
|
||||
Some(ApplicationInfo::new(process_id as i32, name, process_id))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn tap_audio(
|
||||
_process_id: u32, // Currently unused - Windows captures global audio
|
||||
audio_stream_callback: ThreadsafeFunction<napi::bindgen_prelude::Float32Array, ()>,
|
||||
) -> Result<AudioCaptureSession> {
|
||||
// On Windows with CPAL, we capture global audio (mic + loopback)
|
||||
// since per-application audio tapping isn't supported the same way as macOS
|
||||
crate::windows::audio_capture::start_recording(audio_stream_callback)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn tap_global_audio(
|
||||
_excluded_processes: Option<Vec<&ApplicationInfo>>,
|
||||
audio_stream_callback: ThreadsafeFunction<napi::bindgen_prelude::Float32Array, ()>,
|
||||
) -> Result<AudioCaptureSession> {
|
||||
// Delegate to audio_capture::start_recording which handles mixing mic +
|
||||
// loopback
|
||||
crate::windows::audio_capture::start_recording(audio_stream_callback)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn is_using_microphone(process_id: u32) -> Result<bool> {
|
||||
is_process_actively_using_microphone(process_id)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export the concrete audio capture session implemented in audio_capture.rs
|
||||
pub use crate::windows::audio_capture::AudioCaptureSession;
|
||||
|
||||
// Helper function to escape invalid filename characters
|
||||
fn escape_filename(path: &str) -> String {
|
||||
// Replace invalid filename characters with underscores
|
||||
// Invalid chars on Windows: < > : " | ? * \ spaces and control chars (0-31)
|
||||
path
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'<' | '>' | ':' | '"' | '|' | '?' | '*' | '\\' | ' ' => '_',
|
||||
c if c.is_control() => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect::<String>()
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
// Helper functions for Windows process management
|
||||
|
||||
fn get_running_processes() -> Vec<u32> {
|
||||
let mut processes_set = HashSet::new(); // Use HashSet to avoid duplicates from the start
|
||||
unsafe {
|
||||
let h_snapshot_result = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||
|
||||
let h_snapshot = match h_snapshot_result {
|
||||
Ok(handle) => {
|
||||
if handle == INVALID_HANDLE_VALUE {
|
||||
// eprintln!("CreateToolhelp32Snapshot returned INVALID_HANDLE_VALUE");
|
||||
return Vec::new();
|
||||
}
|
||||
handle
|
||||
}
|
||||
Err(_e) => {
|
||||
// eprintln!("CreateToolhelp32Snapshot failed: {:?}", e);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
let mut pe32 = PROCESSENTRY32W::default();
|
||||
pe32.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
|
||||
|
||||
if Process32FirstW(h_snapshot, &mut pe32).is_ok() {
|
||||
loop {
|
||||
processes_set.insert(pe32.th32ProcessID);
|
||||
if Process32NextW(h_snapshot, &mut pe32).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
CloseHandle(h_snapshot).unwrap_or_else(|_e| {
|
||||
// eprintln!("CloseHandle failed for snapshot: {:?}", e);
|
||||
});
|
||||
}
|
||||
let mut processes_vec: Vec<u32> = processes_set.into_iter().collect();
|
||||
processes_vec.sort_unstable(); // Sort for consistent ordering, though not strictly necessary for functionality
|
||||
processes_vec
|
||||
}
|
||||
|
||||
fn is_process_running(process_id: u32) -> bool {
|
||||
unsafe {
|
||||
match OpenProcess(PROCESS_QUERY_INFORMATION, false, process_id) {
|
||||
Ok(handle) => CloseHandle(handle).is_ok(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_process_name(pid: u32) -> Option<String> {
|
||||
unsafe {
|
||||
let process_handle =
|
||||
OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid).ok()?;
|
||||
// Allocate a buffer large enough to hold extended-length paths (up to ~32K
|
||||
// characters) instead of the legacy MAX_PATH (260) limit. 32 768 is the
|
||||
// maximum length supported by the Win32 APIs when the path is prefixed
|
||||
// with "\\?\".
|
||||
let mut buffer: Vec<u16> = std::iter::repeat(0).take(32_768).collect();
|
||||
|
||||
let length = GetModuleFileNameExW(Some(process_handle), None, &mut buffer);
|
||||
CloseHandle(process_handle).ok()?;
|
||||
|
||||
if length == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Truncate the buffer to the length returned by the Windows API before
|
||||
// doing the UTF-16 → UTF-8 conversion.
|
||||
buffer.truncate(length as usize);
|
||||
let os_string = OsString::from_wide(&buffer);
|
||||
let path_str = os_string.to_string_lossy().to_string();
|
||||
path_str.rsplit('\\').next().map(|s| s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn get_process_executable_path(pid: u32) -> Option<String> {
|
||||
unsafe {
|
||||
let process_handle =
|
||||
OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid).ok()?;
|
||||
// Use a buffer that can hold extended-length paths. See rationale above.
|
||||
let mut buffer: Vec<u16> = std::iter::repeat(0).take(32_768).collect();
|
||||
|
||||
let length = GetProcessImageFileNameW(process_handle, &mut buffer);
|
||||
CloseHandle(process_handle).ok()?;
|
||||
|
||||
if length == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
buffer.truncate(length as usize);
|
||||
let os_string = OsString::from_wide(&buffer);
|
||||
let path_str = os_string.to_string_lossy().to_string();
|
||||
Some(path_str)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to start monitoring a specific process
|
||||
fn start_process_monitoring(
|
||||
handle: u32,
|
||||
process_id: u32,
|
||||
callback: Arc<ThreadsafeFunction<(), ()>>,
|
||||
) {
|
||||
let should_stop = Arc::new(AtomicBool::new(false));
|
||||
let should_stop_clone = should_stop.clone();
|
||||
|
||||
// Store the watcher info
|
||||
if let Ok(mut watchers) = ACTIVE_APP_WATCHERS.write() {
|
||||
watchers.push((handle, process_id, callback.clone(), should_stop.clone()));
|
||||
}
|
||||
|
||||
// Start monitoring thread
|
||||
thread::spawn(move || {
|
||||
let mut last_state = false;
|
||||
|
||||
loop {
|
||||
if should_stop_clone.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check current microphone state
|
||||
let current_state = is_process_actively_using_microphone(process_id).unwrap_or(false);
|
||||
|
||||
// If state changed, trigger callback
|
||||
if current_state != last_state {
|
||||
let _ = callback.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
last_state = current_state;
|
||||
}
|
||||
|
||||
// Sleep for a short interval before checking again
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to start monitoring application list changes
|
||||
fn start_list_monitoring(handle: u32, callback: Arc<ThreadsafeFunction<(), ()>>) {
|
||||
let should_stop = Arc::new(AtomicBool::new(false));
|
||||
let should_stop_clone = should_stop.clone();
|
||||
|
||||
// Store the watcher info
|
||||
if let Ok(mut watchers) = ACTIVE_LIST_WATCHERS.write() {
|
||||
watchers.push((handle, callback.clone(), should_stop.clone()));
|
||||
}
|
||||
|
||||
// Start monitoring thread
|
||||
thread::spawn(move || {
|
||||
let mut last_processes = get_running_processes();
|
||||
|
||||
loop {
|
||||
if should_stop_clone.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check current process list
|
||||
let current_processes = get_running_processes();
|
||||
|
||||
// If process list changed, trigger callback
|
||||
if current_processes != last_processes {
|
||||
let _ = callback.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
last_processes = current_processes;
|
||||
|
||||
// Update global process list
|
||||
if let Ok(mut apps) = RUNNING_APPLICATIONS.write() {
|
||||
*apps = last_processes.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep for a longer interval for process list changes
|
||||
thread::sleep(Duration::from_millis(2000));
|
||||
}
|
||||
});
|
||||
}
|
@ -25,7 +25,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "3.0.0-alpha.81",
|
||||
"@napi-rs/cli": "3.0.0-alpha.89",
|
||||
"@napi-rs/whisper": "^0.0.4",
|
||||
"@types/node": "^22.0.0",
|
||||
"ava": "^6.2.0",
|
||||
|
14
yarn.lock
14
yarn.lock
@ -802,7 +802,7 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@affine/native@workspace:packages/frontend/native"
|
||||
dependencies:
|
||||
"@napi-rs/cli": "npm:3.0.0-alpha.81"
|
||||
"@napi-rs/cli": "npm:3.0.0-alpha.89"
|
||||
"@napi-rs/whisper": "npm:^0.0.4"
|
||||
"@types/node": "npm:^22.0.0"
|
||||
ava: "npm:^6.2.0"
|
||||
@ -887,7 +887,7 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@affine/server-native@workspace:packages/backend/native"
|
||||
dependencies:
|
||||
"@napi-rs/cli": "npm:3.0.0-alpha.81"
|
||||
"@napi-rs/cli": "npm:3.0.0-alpha.89"
|
||||
lib0: "npm:^0.2.99"
|
||||
tiktoken: "npm:^1.0.17"
|
||||
tinybench: "npm:^4.0.0"
|
||||
@ -7941,9 +7941,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@napi-rs/cli@npm:3.0.0-alpha.81":
|
||||
version: 3.0.0-alpha.81
|
||||
resolution: "@napi-rs/cli@npm:3.0.0-alpha.81"
|
||||
"@napi-rs/cli@npm:3.0.0-alpha.89":
|
||||
version: 3.0.0-alpha.89
|
||||
resolution: "@napi-rs/cli@npm:3.0.0-alpha.89"
|
||||
dependencies:
|
||||
"@inquirer/prompts": "npm:^7.4.0"
|
||||
"@napi-rs/cross-toolchain": "npm:^0.0.19"
|
||||
@ -7970,7 +7970,7 @@ __metadata:
|
||||
bin:
|
||||
napi: ./dist/cli.js
|
||||
napi-raw: ./cli.mjs
|
||||
checksum: 10/1b086706f753141d3632dd49bfeb2539c1e67af7c362da937cbd0cbad1c8578cf088d2afedc3a86302fb77e3dc7784c096081dc1b4b9e1d1a3c6bffe6308a5ff
|
||||
checksum: 10/8ba4122d1bf42bf844c8304e374aa6f08a7a2804cf0d45d9a0007820076b1560cb9c8d78a91c5c3c0b8a10e474f9277fc5faab78bbe87643a2ff2027f2129b11
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -16363,7 +16363,7 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@y-octo/node@workspace:packages/common/y-octo/node"
|
||||
dependencies:
|
||||
"@napi-rs/cli": "npm:3.0.0-alpha.81"
|
||||
"@napi-rs/cli": "npm:3.0.0-alpha.89"
|
||||
"@types/node": "npm:^22.14.1"
|
||||
"@types/prompts": "npm:^2.4.9"
|
||||
c8: "npm:^10.1.3"
|
||||
|
Loading…
x
Reference in New Issue
Block a user