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:
Peng Xiao 2025-06-18 13:57:01 +08:00 committed by GitHub
parent c844786a7f
commit 899ffd1ad3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 2509 additions and 458 deletions

332
Cargo.lock generated
View File

@ -77,8 +77,10 @@ version = "0.0.0"
dependencies = [ dependencies = [
"block2", "block2",
"core-foundation", "core-foundation",
"coreaudio-rs", "coreaudio-rs 0.12.1",
"cpal",
"criterion2", "criterion2",
"crossbeam-channel",
"dispatch2", "dispatch2",
"libc", "libc",
"napi", "napi",
@ -91,6 +93,8 @@ dependencies = [
"symphonia", "symphonia",
"thiserror 2.0.12", "thiserror 2.0.12",
"uuid", "uuid",
"windows 0.61.1",
"windows-core 0.61.2",
] ]
[[package]] [[package]]
@ -213,6 +217,28 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 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]] [[package]]
name = "android-tzdata" name = "android-tzdata"
version = "0.1.1" version = "0.1.1"
@ -695,9 +721,17 @@ version = "1.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16595d3be041c03b09d08d0858631facccee9221e579704070e6e9e4915d3bc7" checksum = "16595d3be041c03b09d08d0858631facccee9221e579704070e6e9e4915d3bc7"
dependencies = [ dependencies = [
"jobserver",
"libc",
"shlex", "shlex",
] ]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]] [[package]]
name = "cexpr" name = "cexpr"
version = "0.6.0" version = "0.6.0"
@ -853,6 +887,16 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 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]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@ -979,6 +1023,17 @@ dependencies = [
"libm", "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]] [[package]]
name = "coreaudio-rs" name = "coreaudio-rs"
version = "0.12.1" version = "0.12.1"
@ -999,6 +1054,29 @@ dependencies = [
"bindgen", "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]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@ -1085,6 +1163,15 @@ dependencies = [
"walkdir", "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]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
version = "0.8.6" version = "0.8.6"
@ -1165,6 +1252,12 @@ dependencies = [
"parking_lot_core", "parking_lot_core",
] ]
[[package]]
name = "dasp_sample"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"
@ -1820,7 +1913,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"wasm-bindgen", "wasm-bindgen",
"windows-core 0.61.2", "windows-core 0.57.0",
] ]
[[package]] [[package]]
@ -2082,6 +2175,38 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" 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]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.77" version = "0.3.77"
@ -2159,7 +2284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"windows-targets 0.52.6", "windows-targets 0.48.5",
] ]
[[package]] [[package]]
@ -2264,6 +2389,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mach2"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "malloc_buf" name = "malloc_buf"
version = "0.0.6" version = "0.0.6"
@ -2370,9 +2504,9 @@ dependencies = [
[[package]] [[package]]
name = "napi" name = "napi"
version = "3.0.0-beta.3" version = "3.0.0-beta.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a5c343e6e1fb57bf3ea3386638c4affb394ee932708128840a56aaac3d6a8ab" checksum = "c502f122fc89e92c6222810b3144411c6f945da5aa3b713ddfad3bdcae7c9bb4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bitflags 2.9.1", "bitflags 2.9.1",
@ -2380,21 +2514,23 @@ dependencies = [
"ctor", "ctor",
"napi-build", "napi-build",
"napi-sys", "napi-sys",
"nohash-hasher",
"rustc-hash 2.1.1",
"serde", "serde",
"tokio", "tokio",
] ]
[[package]] [[package]]
name = "napi-build" name = "napi-build"
version = "2.2.0" version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03acbfa4f156a32188bfa09b86dc11a431b5725253fc1fc6f6df5bed273382c4" checksum = "44e0e3177307063d3e7e55b7dd7b648cca9d7f46daa35422c0d98cc2bf48c2c1"
[[package]] [[package]]
name = "napi-derive" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d23065ee795a4b1a8755fdf4a39c2a229679f01f923a8feea33f045d6d96cb" checksum = "fcf1e732a67e934b069d6d527251d6288753a36840572abe132a7aed9e77f0bc"
dependencies = [ dependencies = [
"convert_case 0.8.0", "convert_case 0.8.0",
"ctor", "ctor",
@ -2406,9 +2542,9 @@ dependencies = [
[[package]] [[package]]
name = "napi-derive-backend" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "348aaac2c51b5d11cf90cf7670b470c7f4d1607d15c338efd4d3db361003e4f5" checksum = "462b775ba74791c98989fadc46c4bb2ec53016427be4d420d31c4bbaab34b308"
dependencies = [ dependencies = [
"convert_case 0.8.0", "convert_case 0.8.0",
"proc-macro2", "proc-macro2",
@ -2419,13 +2555,42 @@ dependencies = [
[[package]] [[package]]
name = "napi-sys" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b443b980b2258dbaa31b99115e74da6c0866e537278309d566b4672a2f8df516" checksum = "c4401c63f866b42d673a8b213d5662c84a0701b0f6c3acff7e2b9fc439f1675d"
dependencies = [ dependencies = [
"libloading", "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]] [[package]]
name = "new_debug_unreachable" name = "new_debug_unreachable"
version = "1.0.6" version = "1.0.6"
@ -2444,6 +2609,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@ -2510,6 +2681,17 @@ dependencies = [
"num-traits", "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]] [[package]]
name = "num-integer" name = "num-integer"
version = "0.1.46" version = "0.1.46"
@ -2628,6 +2810,29 @@ dependencies = [
"memchr", "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]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.3"
@ -5003,6 +5208,19 @@ dependencies = [
"wasm-bindgen-shared", "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]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.100" version = "0.2.100"
@ -5116,7 +5334,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.48.0",
] ]
[[package]] [[package]]
@ -5125,6 +5343,16 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 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]] [[package]]
name = "windows" name = "windows"
version = "0.57.0" version = "0.57.0"
@ -5157,6 +5385,16 @@ dependencies = [
"windows-core 0.61.2", "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]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.57.0" version = "0.57.0"
@ -5280,6 +5518,15 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.48.0" version = "0.48.0"
@ -5307,6 +5554,21 @@ dependencies = [
"windows-targets 0.52.6", "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]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.48.5" version = "0.48.5"
@ -5347,6 +5609,12 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.48.5" version = "0.48.5"
@ -5359,6 +5627,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.48.5" version = "0.48.5"
@ -5371,6 +5645,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.48.5" version = "0.48.5"
@ -5389,6 +5669,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.48.5" version = "0.48.5"
@ -5401,6 +5687,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 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]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.48.5" version = "0.48.5"
@ -5413,6 +5705,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 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]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.48.5" version = "0.48.5"
@ -5425,6 +5723,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 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]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.48.5" version = "0.48.5"

View File

@ -28,12 +28,14 @@ base64-simd = "0.8"
bitvec = "1.0" bitvec = "1.0"
block2 = "0.6" block2 = "0.6"
byteorder = "1.5" byteorder = "1.5"
cpal = "0.15"
chrono = "0.4" chrono = "0.4"
clap = { version = "4.4", features = ["derive"] } clap = { version = "4.4", features = ["derive"] }
core-foundation = "0.10" core-foundation = "0.10"
coreaudio-rs = "0.12" coreaudio-rs = "0.12"
criterion = { version = "0.5", features = ["html_reports"] } criterion = { version = "0.5", features = ["html_reports"] }
criterion2 = { version = "3", default-features = false } criterion2 = { version = "3", default-features = false }
crossbeam-channel = "0.5"
dispatch2 = "0.3" dispatch2 = "0.3"
docx-parser = { git = "https://github.com/toeverything/docx-parser" } docx-parser = { git = "https://github.com/toeverything/docx-parser" }
dotenvy = "0.15" dotenvy = "0.15"
@ -97,6 +99,19 @@ uniffi = "0.29"
url = { version = "2.5" } url = { version = "2.5" }
uuid = "1.8" uuid = "1.8"
v_htmlescape = "0.15" 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-octo = { path = "./packages/common/y-octo/core" }
y-sync = { version = "0.4" } y-sync = { version = "0.4" }
yrs = "0.23.0" yrs = "0.23.0"

View File

@ -32,7 +32,7 @@
"build:debug": "napi build" "build:debug": "napi build"
}, },
"devDependencies": { "devDependencies": {
"@napi-rs/cli": "3.0.0-alpha.81", "@napi-rs/cli": "3.0.0-alpha.89",
"lib0": "^0.2.99", "lib0": "^0.2.99",
"tiktoken": "^1.0.17", "tiktoken": "^1.0.17",
"tinybench": "^4.0.0", "tinybench": "^4.0.0",

View File

@ -21,7 +21,7 @@
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@napi-rs/cli": "3.0.0-alpha.81", "@napi-rs/cli": "3.0.0-alpha.89",
"@types/node": "^22.14.1", "@types/node": "^22.14.1",
"@types/prompts": "^2.4.9", "@types/prompts": "^2.4.9",
"c8": "^10.1.3", "c8": "^10.1.3",

View File

@ -1,3 +1,4 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css'; import { globalStyle, style } from '@vanilla-extract/css';
globalStyle('html', { globalStyle('html', {
@ -13,4 +14,9 @@ export const root = style({
width: '100%', width: '100%',
height: '100%', height: '100%',
userSelect: 'none', userSelect: 'none',
selectors: {
'&[data-is-windows]': {
backgroundColor: cssVarV2('layer/background/primary'),
},
},
}); });

View File

@ -25,7 +25,7 @@ export function App() {
<FrameworkRoot framework={frameworkProvider}> <FrameworkRoot framework={frameworkProvider}>
<ThemeProvider> <ThemeProvider>
<I18nProvider> <I18nProvider>
<div className={styles.root}> <div className={styles.root} data-is-windows={environment.isWindows}>
{mode === 'recording' && <Recording />} {mode === 'recording' && <Recording />}
</div> </div>
</I18nProvider> </I18nProvider>

View File

@ -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 { app, net, protocol, session } from 'electron';
import cookieParser from 'set-cookie-parser'; import cookieParser from 'set-cookie-parser';
import { resourcesPath } from '../shared/utils'; import { isWindows, resourcesPath } from '../shared/utils';
import { anotherHost, mainHost } from './constants'; import { anotherHost, mainHost } from './constants';
import { logger } from './logger'; import { logger } from './logger';
@ -77,17 +78,23 @@ async function handleFileRequest(request: Request) {
} }
} else { } else {
filepath = decodeURIComponent(urlObject.pathname); 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') // security check if the filepath is within app.getPath('sessionData')
const sessionDataPath = app.getPath('sessionData'); const sessionDataPath = path
const tempPath = app.getPath('temp'); .resolve(app.getPath('sessionData'))
.toLowerCase();
const tempPath = path.resolve(app.getPath('temp')).toLowerCase();
if ( if (
!filepath.startsWith(sessionDataPath) && !filepath.toLowerCase().startsWith(sessionDataPath) &&
!filepath.startsWith(tempPath) !filepath.toLowerCase().startsWith(tempPath)
) { ) {
throw new Error('Invalid filepath'); throw new Error('Invalid filepath');
} }
} }
return net.fetch('file://' + filepath, clonedRequest); return net.fetch(pathToFileURL(filepath).toString(), clonedRequest);
} }
export function registerProtocol() { export function registerProtocol() {

View File

@ -1,10 +1,11 @@
/* oxlint-disable no-var-requires */ /* oxlint-disable no-var-requires */
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fsp from 'node:fs/promises'; import fsp from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
// Should not load @affine/native for unsupported platforms // 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 { app, systemPreferences } from 'electron';
import fs from 'fs-extra'; import fs from 'fs-extra';
import { debounce } from 'lodash-es'; import { debounce } from 'lodash-es';
@ -19,7 +20,7 @@ import {
} from 'rxjs'; } from 'rxjs';
import { filter, map, shareReplay } from 'rxjs/operators'; 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 { beforeAppQuit } from '../cleanup';
import { logger } from '../logger'; import { logger } from '../logger';
import { import {
@ -64,7 +65,7 @@ export const SAVED_RECORDINGS_DIR = path.join(
'recordings' 'recordings'
); );
let shareableContent: ShareableContent | null = null; let shareableContent: ShareableContentType | null = null;
function cleanup() { function cleanup() {
shareableContent = null; shareableContent = null;
@ -95,8 +96,10 @@ const recordings = new Map<number, Recording>();
export const recordingStatus$ = recordingStateMachine.status$; export const recordingStatus$ = recordingStateMachine.status$;
function createAppGroup(processGroupId: number): AppGroupInfo | undefined { function createAppGroup(processGroupId: number): AppGroupInfo | undefined {
const groupProcess = // MUST require dynamically to avoid loading @affine/native for unsupported platforms
shareableContent?.applicationWithProcessId(processGroupId); const SC: typeof ShareableContentType =
require('@affine/native').ShareableContent;
const groupProcess = SC?.applicationWithProcessId(processGroupId);
if (!groupProcess) { if (!groupProcess) {
return; 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) { export function createRecording(status: RecordingStatus) {
let recording = recordings.get(status.id); let recording = recordings.get(status.id);
if (recording) { if (recording) {
return recording; return recording;
} }
const appId = getSanitizedAppId(status.appGroup?.bundleIdentifier);
const bufferedFilePath = path.join( const bufferedFilePath = path.join(
SAVED_RECORDINGS_DIR, SAVED_RECORDINGS_DIR,
`${status.appGroup?.bundleIdentifier ?? 'unknown'}-${status.id}-${status.startTime}.raw` `${appId}-${status.id}-${status.startTime}.raw`
); );
fs.ensureDirSync(SAVED_RECORDINGS_DIR); 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 // 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 const stream = status.app
? status.app.rawInstance.tapAudio(tapAudioSamples) ? SC.tapAudio(status.app.processId, tapAudioSamples)
: ShareableContent.tapGlobalAudio(null, tapAudioSamples); : SC.tapGlobalAudio(null, tapAudioSamples);
recording = { recording = {
id: status.id, id: status.id,
@ -379,15 +398,24 @@ function getAllApps(): TappableAppInfo[] {
if (!shareableContent) { if (!shareableContent) {
return []; 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 { try {
// Check if this process is actively using microphone/audio
const isRunning = ShareableContent.isUsingMicrophone(app.processId);
return { return {
rawInstance: app, info: app,
processId: app.processId, processId: app.processId,
processGroupId: app.processGroupId, processGroupId: app.processGroupId,
bundleIdentifier: app.bundleIdentifier, bundleIdentifier: app.bundleIdentifier,
name: app.name, name: app.name,
isRunning: app.isRunning, isRunning,
}; };
} catch (error) { } catch (error) {
logger.error('failed to get app info', error); logger.error('failed to get app info', error);
@ -441,15 +469,15 @@ function setupMediaListeners() {
apps.forEach(app => { apps.forEach(app => {
try { try {
const tappableApp = app.rawInstance; const applicationInfo = app.info;
_appStateSubscribers.push( _appStateSubscribers.push(
ShareableContent.onAppStateChanged(tappableApp, () => { ShareableContent.onAppStateChanged(applicationInfo, () => {
updateApplicationsPing$.next(Date.now()); updateApplicationsPing$.next(Date.now());
}) })
); );
} catch (error) { } catch (error) {
logger.error( logger.error(
`Failed to convert app ${app.name} to TappableApplication`, `Failed to set up app state listener for ${app.name}`,
error error
); );
} }
@ -668,15 +696,18 @@ export async function readyRecording(id: number, buffer: Buffer) {
return; return;
} }
const filepath = path.join( const rawFilePath = String(recording.file.path);
SAVED_RECORDINGS_DIR,
`${recordingStatus.appGroup?.bundleIdentifier ?? 'unknown'}-${recordingStatus.id}-${recordingStatus.startTime}.opus` const filepath = rawFilePath.replace('.raw', '.opus');
);
if (!filepath) {
logger.error(`readyRecording: Recording ${id} has no filepath`);
return;
}
await fs.writeFile(filepath, buffer); await fs.writeFile(filepath, buffer);
// can safely remove the raw file now // can safely remove the raw file now
const rawFilePath = recording.file.path;
logger.info('remove raw file', rawFilePath); logger.info('remove raw file', rawFilePath);
if (rawFilePath) { if (rawFilePath) {
try { try {
@ -768,14 +799,24 @@ export const getMacOSVersion = () => {
// check if the system is MacOS and the version is >= 14.2 // check if the system is MacOS and the version is >= 14.2
export const checkRecordingAvailable = () => { export const checkRecordingAvailable = () => {
if (!isMacOS()) { if (isMacOS()) {
return false; const version = getMacOSVersion();
return (version.major === 14 && version.minor >= 2) || version.major > 14;
} }
const version = getMacOSVersion(); if (isWindows()) {
return (version.major === 14 && version.minor >= 2) || version.major > 14; return true;
}
return false;
}; };
export const checkMeetingPermissions = () => { export const checkMeetingPermissions = () => {
if (isWindows()) {
return {
screen: true,
microphone: true,
};
}
if (!isMacOS()) { if (!isMacOS()) {
return undefined; return undefined;
} }

View File

@ -1,9 +1,9 @@
import type { WriteStream } from 'node:fs'; import type { WriteStream } from 'node:fs';
import type { AudioCaptureSession, TappableApplication } from '@affine/native'; import type { ApplicationInfo, AudioCaptureSession } from '@affine/native';
export interface TappableAppInfo { export interface TappableAppInfo {
rawInstance: TappableApplication; info: ApplicationInfo;
isRunning: boolean; isRunning: boolean;
processId: number; processId: number;
processGroupId: number; processGroupId: number;

View File

@ -77,6 +77,7 @@ abstract class PopupWindow {
closable: false, closable: false,
alwaysOnTop: true, alwaysOnTop: true,
hiddenInMissionControl: true, hiddenInMissionControl: true,
skipTaskbar: true,
movable: false, movable: false,
titleBarStyle: 'hidden', titleBarStyle: 'hidden',
show: false, // hide by default, show: false, // hide by default,
@ -243,6 +244,8 @@ export class PopupManager {
return new NotificationPopupWindow() as PopupWindowTypeMap[T]; return new NotificationPopupWindow() as PopupWindowTypeMap[T];
case 'recording': case 'recording':
return new RecordingPopupWindow() as PopupWindowTypeMap[T]; return new RecordingPopupWindow() as PopupWindowTypeMap[T];
default:
throw new Error(`Unknown popup type: ${type}`);
} }
})(); })();

View File

@ -95,7 +95,10 @@ export const useGeneralSettingList = (): GeneralSettingList => {
}); });
} }
if (environment.isMacOs && BUILD_CONFIG.isElectron) { if (
(environment.isMacOs || environment.isWindows) &&
BUILD_CONFIG.isElectron
) {
settings.push({ settings.push({
key: 'meetings', key: 'meetings',
title: t['com.affine.settings.meetings'](), title: t['com.affine.settings.meetings'](),

View File

@ -295,28 +295,31 @@ const MeetingsSettingsMain = () => {
/> />
</SettingRow> </SettingRow>
</SettingWrapper> </SettingWrapper>
<SettingWrapper {environment.isMacOs && (
title={t['com.affine.settings.meetings.privacy.header']()} <SettingWrapper
> title={t['com.affine.settings.meetings.privacy.header']()}
<PermissionSettingRow >
nameKey="com.affine.settings.meetings.privacy.screen-system-audio-recording" <PermissionSettingRow
descriptionKey="com.affine.settings.meetings.privacy.screen-system-audio-recording.description" nameKey="com.affine.settings.meetings.privacy.screen-system-audio-recording"
permissionSettingKey="com.affine.settings.meetings.privacy.screen-system-audio-recording.permission-setting" descriptionKey="com.affine.settings.meetings.privacy.screen-system-audio-recording.description"
hasPermission={permissions?.screen || false} permissionSettingKey="com.affine.settings.meetings.privacy.screen-system-audio-recording.permission-setting"
onOpenPermissionSetting={() => hasPermission={permissions?.screen || false}
handleOpenPermissionSetting('screen') onOpenPermissionSetting={() =>
} handleOpenPermissionSetting('screen')
/> }
<PermissionSettingRow />
nameKey="com.affine.settings.meetings.privacy.microphone" <PermissionSettingRow
descriptionKey="com.affine.settings.meetings.privacy.microphone.description" nameKey="com.affine.settings.meetings.privacy.microphone"
permissionSettingKey="com.affine.settings.meetings.privacy.microphone.permission-setting" descriptionKey="com.affine.settings.meetings.privacy.microphone.description"
hasPermission={permissions?.microphone || false} permissionSettingKey="com.affine.settings.meetings.privacy.microphone.permission-setting"
onOpenPermissionSetting={() => hasPermission={permissions?.microphone || false}
handleOpenPermissionSetting('microphone') onOpenPermissionSetting={() =>
} handleOpenPermissionSetting('microphone')
/> }
</SettingWrapper> />
</SettingWrapper>
)}
<SettingWrapper> <SettingWrapper>
<SettingRow <SettingRow
name={t['com.affine.settings.meetings.privacy.issues']()} name={t['com.affine.settings.meetings.privacy.issues']()}

View File

@ -3,10 +3,9 @@ import { createServer } from 'node:http';
import path from 'node:path'; import path from 'node:path';
import { import {
type Application, type ApplicationInfo,
type AudioCaptureSession, type AudioCaptureSession,
ShareableContent, ShareableContent,
type TappableApplication,
} from '@affine/native'; } from '@affine/native';
import type { FSWatcher } from 'chokidar'; import type { FSWatcher } from 'chokidar';
import chokidar from 'chokidar'; import chokidar from 'chokidar';
@ -35,8 +34,8 @@ console.log(`📁 Ensuring recordings directory exists at ${RECORDING_DIR}`);
// Types // Types
interface Recording { interface Recording {
app: TappableApplication | null; app: ApplicationInfo | null;
appGroup: Application | null; appGroup: ApplicationInfo | null;
buffers: Float32Array[]; buffers: Float32Array[];
session: AudioCaptureSession; session: AudioCaptureSession;
startTime: number; startTime: number;
@ -66,7 +65,7 @@ interface RecordingMetadata {
} }
interface AppInfo { interface AppInfo {
app?: TappableApplication; app?: ApplicationInfo;
processId: number; processId: number;
processGroupId: number; processGroupId: number;
bundleIdentifier: string; 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 // Recording management
async function saveRecording( async function saveRecording(
recording: Recording, recording: Recording,
@ -176,9 +200,9 @@ async function saveRecording(
if (recording.isGlobal) { if (recording.isGlobal) {
console.log('💾 Saving global recording:'); console.log('💾 Saving global recording:');
} else { } else {
const appName = app?.name ?? 'Unknown App'; const appName = getAppName(app);
const processId = app?.processId ?? 0; const processId = getAppProcessId(app);
const bundleId = app?.bundleIdentifier ?? 'unknown'; const bundleId = getAppBundleIdentifier(app);
console.log(`💾 Saving recording for ${appName}:`); console.log(`💾 Saving recording for ${appName}:`);
if (app) { if (app) {
console.log(`- Process ID: ${processId}`); console.log(`- Process ID: ${processId}`);
@ -208,7 +232,7 @@ async function saveRecording(
const timestamp = Date.now(); const timestamp = Date.now();
const baseFilename = recording.isGlobal const baseFilename = recording.isGlobal
? `global-recording-${timestamp}` ? `global-recording-${timestamp}`
: `${app?.bundleIdentifier ?? 'unknown'}-${app?.processId ?? 0}-${timestamp}`; : `${getAppBundleIdentifier(app)}-${getAppProcessId(app)}-${timestamp}`;
// Sanitize the baseFilename to prevent path traversal // Sanitize the baseFilename to prevent path traversal
const sanitizedFilename = baseFilename const sanitizedFilename = baseFilename
@ -249,18 +273,19 @@ async function saveRecording(
console.log('✅ Transcription Wav file written successfully'); console.log('✅ Transcription Wav file written successfully');
// Save app icon if available // Save app icon if available
if (app?.icon) { const appIcon = getAppIcon(app);
if (appIcon) {
console.log(`📝 Writing app icon to ${iconFilename}`); 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('✅ App icon written successfully');
} }
console.log(`📝 Writing metadata to ${metadataFilename}`); console.log(`📝 Writing metadata to ${metadataFilename}`);
// Save metadata with the actual sample rate from the stream // Save metadata with the actual sample rate from the stream
const metadata: RecordingMetadata = { const metadata: RecordingMetadata = {
appName: app?.name ?? 'Global Recording', appName: getAppName(app),
bundleIdentifier: app?.bundleIdentifier ?? 'system.global', bundleIdentifier: getAppBundleIdentifier(app),
processId: app?.processId ?? -1, processId: getAppProcessId(app),
recordingStartTime: recording.startTime, recordingStartTime: recording.startTime,
recordingEndTime, recordingEndTime,
recordingDuration, recordingDuration,
@ -283,8 +308,8 @@ async function saveRecording(
function getRecordingStatus(): RecordingStatus[] { function getRecordingStatus(): RecordingStatus[] {
return Array.from(recordingMap.entries()).map(([processId, recording]) => ({ return Array.from(recordingMap.entries()).map(([processId, recording]) => ({
processId, processId,
bundleIdentifier: recording.app?.bundleIdentifier ?? 'system.global', bundleIdentifier: getAppBundleIdentifier(recording.app),
name: recording.app?.name ?? 'Global Recording', name: getAppName(recording.app),
startTime: recording.startTime, startTime: recording.startTime,
duration: Date.now() - recording.startTime, duration: Date.now() - recording.startTime,
})); }));
@ -294,41 +319,53 @@ function emitRecordingStatus() {
io.emit('apps:recording', { recordings: getRecordingStatus() }); io.emit('apps:recording', { recordings: getRecordingStatus() });
} }
async function startRecording(app: TappableApplication) { async function startRecording(app: ApplicationInfo) {
if (recordingMap.has(app.processId)) { const appProcessId = getAppProcessId(app);
const appName = getAppName(app);
const appBundleId = getAppBundleIdentifier(app);
if (recordingMap.has(appProcessId)) {
console.log( console.log(
`⚠️ Recording already in progress for ${app.name} (PID: ${app.processId})` `⚠️ Recording already in progress for ${appName} (PID: ${appProcessId})`
); );
return; return;
} }
try { try {
console.log(
`🎙️ Starting recording for ${appName} (Bundle: ${appBundleId}, PID: ${appProcessId})`
);
const processGroupId = app.processGroupId; const processGroupId = app.processGroupId;
const rootApp = const rootApp =
shareableContent.applicationWithProcessId(processGroupId) || ShareableContent.applicationWithProcessId(processGroupId) ||
shareableContent.applicationWithProcessId(app.processId); ShareableContent.applicationWithProcessId(app.processId);
if (!rootApp) { if (!rootApp) {
console.error(`❌ App group not found for ${app.name}`); console.error(`❌ App group not found for ${appName}`);
return; return;
} }
console.log( console.log(
`🎙️ Starting recording for ${rootApp.name} (PID: ${rootApp.processId})` `🎙️ Recording from ${rootApp.name} (PID: ${rootApp.processId})`
); );
const buffers: Float32Array[] = []; const buffers: Float32Array[] = [];
const session = app.tapAudio((err, samples) => { const session = ShareableContent.tapAudio(
if (err) { appProcessId,
console.error(`❌ Audio stream error for ${rootApp.name}:`, err); (err: any, samples: any) => {
return; 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, app,
appGroup: rootApp, appGroup: rootApp,
buffers, buffers,
@ -340,7 +377,7 @@ async function startRecording(app: TappableApplication) {
console.log(`✅ Recording started successfully for ${rootApp.name}`); console.log(`✅ Recording started successfully for ${rootApp.name}`);
emitRecordingStatus(); emitRecordingStatus();
} catch (error) { } 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 app = recording.appGroup || recording.app;
const appName = const appName = recording.isGlobal
app?.name ?? (recording.isGlobal ? 'Global Recording' : 'Unknown App'); ? 'Global Recording'
const appPid = app?.processId ?? processId; : getAppName(app) || 'Unknown App';
const appPid = getAppProcessId(app);
console.log(`⏹️ Stopping recording for ${appName} (PID: ${appPid})`); console.log(`⏹️ Stopping recording for ${appName} (PID: ${appPid})`);
console.log( 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[]> { async function getAllApps(): Promise<AppInfo[]> {
const apps: (AppInfo | null)[] = shareableContent.applications().map(app => { const apps: (AppInfo | null)[] = ShareableContent.applications().map(app => {
try { try {
return { return {
app, app,
@ -541,7 +583,9 @@ async function getAllApps(): Promise<AppInfo[]> {
processGroupId: app.processGroupId, processGroupId: app.processGroupId,
bundleIdentifier: app.bundleIdentifier, bundleIdentifier: app.bundleIdentifier,
name: app.name, name: app.name,
isRunning: app.isRunning, get isRunning() {
return ShareableContent.isUsingMicrophone(app.processId);
},
}; };
} catch (error) { } catch (error) {
console.error(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) { for (const app of filteredApps) {
if (filteredApps.some(a => a.processId === app.processGroupId)) { const bundleId = app.bundleIdentifier;
continue; if (!bundleGroups.has(bundleId)) {
bundleGroups.set(bundleId, []);
} }
const appGroup = shareableContent.applicationWithProcessId( bundleGroups.get(bundleId)?.push(app);
app.processGroupId
);
if (!appGroup) {
continue;
}
filteredApps.push({
processId: appGroup.processId,
processGroupId: appGroup.processGroupId,
bundleIdentifier: appGroup.bundleIdentifier,
name: appGroup.name,
isRunning: false,
});
} }
// 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( await Promise.all(
Array.from(recordingMap.keys()).map(async processId => { Array.from(recordingMap.keys()).map(async processId => {
if (!filteredApps.some(a => a.processId === processId)) { if (!groupedApps.some(a => a.processId === processId)) {
await stopRecording(processId); await stopRecording(processId);
} }
}) })
); );
listenToAppStateChanges(filteredApps); listenToAppStateChanges(groupedApps);
return filteredApps; return groupedApps;
} }
function listenToAppStateChanges(apps: AppInfo[]) { function listenToAppStateChanges(apps: AppInfo[]) {
@ -597,19 +661,27 @@ function listenToAppStateChanges(apps: AppInfo[]) {
return { unsubscribe: () => {} }; return { unsubscribe: () => {} };
} }
const appName = getAppName(app);
const appProcessId = getAppProcessId(app);
const onAppStateChanged = () => { const onAppStateChanged = () => {
const currentIsRunning =
ShareableContent.isUsingMicrophone(appProcessId);
console.log( console.log(
`🔄 Application state changed: ${app.name} (PID: ${app.processId}) is now ${ `🔄 Application state changed: ${appName} (PID: ${appProcessId}) is now ${
app.isRunning ? '▶️ running' : '⏹️ stopped' currentIsRunning ? '▶️ running' : '⏹️ stopped'
}` }`
); );
// Emit state change to all clients
io.emit('apps:state-changed', { io.emit('apps:state-changed', {
processId: app.processId, processId: appProcessId,
isRunning: app.isRunning, isRunning: currentIsRunning,
}); });
if (!app.isRunning) { if (!currentIsRunning) {
stopRecording(app.processId).catch(error => { stopRecording(appProcessId).catch(error => {
console.error('❌ Error stopping recording:', error); console.error('❌ Error stopping recording:', error);
}); });
} }
@ -621,7 +693,7 @@ function listenToAppStateChanges(apps: AppInfo[]) {
); );
} catch (error) { } catch (error) {
console.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 error
); );
return { unsubscribe: () => {} }; return { unsubscribe: () => {} };
@ -652,7 +724,8 @@ io.on('connection', async socket => {
console.log(`📤 Sending ${files.length} saved recordings to new client`); console.log(`📤 Sending ${files.length} saved recordings to new client`);
socket.emit('apps:saved', { recordings: files }); 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', () => { socket.on('disconnect', () => {
console.log('🔌 Client disconnected'); console.log('🔌 Client disconnected');
@ -667,6 +740,9 @@ ShareableContent.onApplicationListChanged(() => {
const apps = await getAllApps(); const apps = await getAllApps();
console.log(`📢 Broadcasting ${apps.length} applications to all clients`); console.log(`📢 Broadcasting ${apps.length} applications to all clients`);
io.emit('apps:all', { apps }); io.emit('apps:all', { apps });
// Set up state listeners for the updated apps
listenToAppStateChanges(apps.filter(appInfo => appInfo.app != null));
} catch (error) { } catch (error) {
console.error('❌ Error handling application list change:', 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) => { app.get('/apps/:process_id/icon', (req, res) => {
const processId = parseInt(req.params.process_id); const processId = parseInt(req.params.process_id);
try { try {
const app = shareableContent.applicationWithProcessId(processId); const app = ShareableContent.applicationWithProcessId(processId);
if (!app) { if (!app) {
res.status(404).json({ error: 'App not found' }); res.status(404).json({ error: 'App not found' });
return; return;
@ -786,7 +862,7 @@ app.get('/apps/:process_id/icon', (req, res) => {
app.post('/apps/:process_id/record', async (req, res) => { app.post('/apps/:process_id/record', async (req, res) => {
const processId = parseInt(req.params.process_id); const processId = parseInt(req.params.process_id);
try { try {
const app = shareableContent.tappableApplicationWithProcessId(processId); const app = ShareableContent.applicationWithProcessId(processId);
if (!app) { if (!app) {
res.status(404).json({ error: 'App not found' }); res.status(404).json({ error: 'App not found' });
return; return;

View File

@ -1,11 +1,12 @@
/* auto-generated by NAPI-RS */ /* auto-generated by NAPI-RS */
/* eslint-disable */ /* eslint-disable */
export declare class Application { export declare class ApplicationInfo {
constructor(processId: number) processId: number
get processId(): number name: string
objectId: number
constructor(processId: number, name: string, objectId: number)
get processGroupId(): number get processGroupId(): number
get bundleIdentifier(): string get bundleIdentifier(): string
get name(): string
get icon(): Buffer get icon(): Buffer
} }
@ -18,12 +19,30 @@ export declare class ApplicationStateChangedSubscriber {
} }
export declare class AudioCaptureSession { export declare class AudioCaptureSession {
stop(): void
get sampleRate(): number get sampleRate(): number
get channels(): number get channels(): number
get actualSampleRate(): 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 { export declare class DocStorage {
constructor(path: string) constructor(path: string)
validate(): Promise<boolean> validate(): Promise<boolean>
@ -64,21 +83,43 @@ export declare class DocStoragePool {
getBlobUploadedAt(universalId: string, peer: string, blobId: string): Promise<Date | null> getBlobUploadedAt(universalId: string, peer: string, blobId: string): Promise<Date | null>
} }
export declare class RecordingPermissions { export interface Blob {
audio: boolean key: string
screen: boolean data: Uint8Array
mime: string
size: number
createdAt: Date
} }
export declare class ShareableContent { export interface DocClock {
static onApplicationListChanged(callback: ((err: Error | null, ) => void)): ApplicationListChangedSubscriber docId: string
static onAppStateChanged(app: TappableApplication, callback: ((err: Error | null, ) => void)): ApplicationStateChangedSubscriber timestamp: Date
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 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 { export declare class SqliteConnection {
constructor(path: string) constructor(path: string)
connect(): Promise<void> connect(): Promise<void>
@ -117,80 +158,22 @@ export declare class SqliteConnection {
checkpoint(): Promise<void> 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 { export interface BlobRow {
key: string key: string
data: Buffer data: Buffer
timestamp: Date 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 { export interface DocTimestampRow {
docId?: string docId?: string
timestamp: Date timestamp: Date
} }
export interface DocUpdate {
docId: string
timestamp: Date
bin: Uint8Array
}
export interface InsertRow { export interface InsertRow {
docId?: string docId?: string
data: Uint8Array 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 { export interface UpdateRow {
id: number id: number
timestamp: Date timestamp: Date
@ -205,5 +188,3 @@ export declare enum ValidationResult {
GeneralError = 3, GeneralError = 3,
Valid = 4 Valid = 4
} }
export declare function verifyChallengeResponse(response: string, bits: number, resource: string): Promise<boolean>

View File

@ -365,28 +365,27 @@ if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) {
if (!nativeBinding) { if (!nativeBinding) {
if (loadErrors.length > 0) { if (loadErrors.length > 0) {
// TODO Link to documentation with potential fixes throw new Error(
// - The package owner could build/publish bindings for this arch `Cannot find native binding. ` +
// - The user may need to bundle the correct files `npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). ` +
// - The user may need to re-install node_modules to get new packages 'Please try `npm i` again after removing both package-lock.json and node_modules directory.',
throw new Error('Failed to load native binding', { cause: loadErrors }) { cause: loadErrors }
)
} }
throw new Error(`Failed to load native binding`) throw new Error(`Failed to load native binding`)
} }
module.exports = nativeBinding module.exports = nativeBinding
module.exports.Application = nativeBinding.Application module.exports.ApplicationInfo = nativeBinding.ApplicationInfo
module.exports.ApplicationListChangedSubscriber = nativeBinding.ApplicationListChangedSubscriber module.exports.ApplicationListChangedSubscriber = nativeBinding.ApplicationListChangedSubscriber
module.exports.ApplicationStateChangedSubscriber = nativeBinding.ApplicationStateChangedSubscriber module.exports.ApplicationStateChangedSubscriber = nativeBinding.ApplicationStateChangedSubscriber
module.exports.AudioCaptureSession = nativeBinding.AudioCaptureSession 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.ShareableContent = nativeBinding.ShareableContent
module.exports.SqliteConnection = nativeBinding.SqliteConnection
module.exports.TappableApplication = nativeBinding.TappableApplication
module.exports.decodeAudio = nativeBinding.decodeAudio module.exports.decodeAudio = nativeBinding.decodeAudio
module.exports.decodeAudioSync = nativeBinding.decodeAudioSync module.exports.decodeAudioSync = nativeBinding.decodeAudioSync
module.exports.mintChallengeResponse = nativeBinding.mintChallengeResponse module.exports.mintChallengeResponse = nativeBinding.mintChallengeResponse
module.exports.ValidationResult = nativeBinding.ValidationResult
module.exports.verifyChallengeResponse = nativeBinding.verifyChallengeResponse 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

View File

@ -11,11 +11,11 @@ harness = false
name = "mix_audio_samples" name = "mix_audio_samples"
[dependencies] [dependencies]
napi = { workspace = true, features = ["napi4"] } napi = { workspace = true, features = ["napi4"] }
napi-derive = { workspace = true, features = ["type-def"] } napi-derive = { workspace = true, features = ["type-def"] }
rubato = { workspace = true } rubato = { workspace = true }
symphonia = { workspace = true, features = ["all", "opt-simd"] } symphonia = { workspace = true, features = ["all", "opt-simd"] }
thiserror = { workspace = true } thiserror = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
block2 = { workspace = true } block2 = { workspace = true }
@ -28,6 +28,12 @@ objc2-foundation = { workspace = true }
screencapturekit = { workspace = true } screencapturekit = { workspace = true }
uuid = { workspace = true, features = ["v4"] } 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] [dev-dependencies]
criterion2 = { workspace = true } criterion2 = { workspace = true }

View File

@ -2,4 +2,10 @@
pub mod macos; pub mod macos;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub(crate) use macos::*; pub(crate) use macos::*;
#[cfg(target_os = "windows")]
pub mod windows;
#[cfg(target_os = "windows")]
pub use windows::*;
pub mod audio_decoder; pub mod audio_decoder;

View File

@ -22,7 +22,7 @@ use coreaudio::sys::{
}; };
use libc; use libc;
use napi::{ use napi::{
bindgen_prelude::{Buffer, Error, Float32Array, Result, Status}, bindgen_prelude::{Buffer, Error, Result, Status},
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
}; };
use napi_derive::napi; use napi_derive::napi;
@ -90,59 +90,22 @@ static NSRUNNING_APPLICATION_CLASS: LazyLock<Option<&'static AnyClass>> =
LazyLock::new(|| AnyClass::get(c"NSRunningApplication")); LazyLock::new(|| AnyClass::get(c"NSRunningApplication"));
#[napi] #[napi]
pub struct Application { #[derive(Clone)]
pub(crate) process_id: i32, pub struct ApplicationInfo {
pub(crate) name: String, pub process_id: i32,
pub name: String,
pub object_id: u32,
} }
#[napi] #[napi]
impl Application { impl ApplicationInfo {
#[napi(constructor)] #[napi(constructor)]
pub fn new(process_id: i32) -> Result<Self> { pub fn new(process_id: i32, name: String, object_id: u32) -> Self {
// Default values for when we can't get information Self {
let mut app = Self {
process_id, process_id,
name: String::new(), name,
}; object_id,
// 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();
}
}
}
}
}
}
} }
Ok(app)
}
#[napi(getter)]
pub fn process_id(&self) -> i32 {
self.process_id
} }
#[napi(getter)] #[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)] String::new()
pub fn name(&self) -> &str {
&self.name
} }
#[napi(getter)] #[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] #[napi]
pub struct ApplicationListChangedSubscriber { pub struct ApplicationListChangedSubscriber {
listener_block: RcBlock<dyn Fn(u32, *mut c_void)>, listener_block: RcBlock<dyn Fn(u32, *mut c_void)>,
@ -538,19 +399,14 @@ pub struct ShareableContent {
_inner: SCShareableContent, _inner: SCShareableContent,
} }
#[napi]
#[derive(Default)]
pub struct RecordingPermissions {
pub audio: bool,
pub screen: bool,
}
#[napi] #[napi]
impl ShareableContent { impl ShareableContent {
#[napi] #[napi]
pub fn on_application_list_changed( pub fn on_application_list_changed(
callback: Arc<ThreadsafeFunction<(), ()>>, callback: ThreadsafeFunction<(), ()>,
) -> Result<ApplicationListChangedSubscriber> { ) -> Result<ApplicationListChangedSubscriber> {
let callback_arc = Arc::new(callback);
let callback_clone = callback_arc.clone();
let callback_block: RcBlock<dyn Fn(u32, *mut c_void)> = let callback_block: RcBlock<dyn Fn(u32, *mut c_void)> =
RcBlock::new(move |_in_number_addresses, _in_addresses: *mut c_void| { RcBlock::new(move |_in_number_addresses, _in_addresses: *mut c_void| {
if let Err(err) = RUNNING_APPLICATIONS if let Err(err) = RUNNING_APPLICATIONS
@ -565,9 +421,9 @@ impl ShareableContent {
*running_applications = audio_process_list(); *running_applications = audio_process_list();
}) })
{ {
callback.call(Err(err), ThreadsafeFunctionCallMode::NonBlocking); callback_clone.call(Err(err), ThreadsafeFunctionCallMode::NonBlocking);
} else { } else {
callback.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking); callback_clone.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking);
} }
}); });
@ -598,11 +454,12 @@ impl ShareableContent {
#[napi] #[napi]
pub fn on_app_state_changed( pub fn on_app_state_changed(
app: &TappableApplication, app: &ApplicationInfo,
callback: Arc<ThreadsafeFunction<(), ()>>, callback: ThreadsafeFunction<(), ()>,
) -> Result<ApplicationStateChangedSubscriber> { ) -> Result<ApplicationStateChangedSubscriber> {
let id = Uuid::new_v4(); let id = Uuid::new_v4();
let object_id = app.object_id; let object_id = app.object_id;
let callback_arc = Arc::new(callback);
let mut lock = APPLICATION_STATE_CHANGED_SUBSCRIBERS.write().map_err(|_| { let mut lock = APPLICATION_STATE_CHANGED_SUBSCRIBERS.write().map_err(|_| {
Error::new( Error::new(
@ -612,7 +469,7 @@ impl ShareableContent {
})?; })?;
if let Some(subscribers) = lock.get_mut(&object_id) { if let Some(subscribers) = lock.get_mut(&object_id) {
subscribers.insert(id, callback); subscribers.insert(id, callback_arc.clone());
} else { } else {
let list_change: RcBlock<dyn Fn(u32, *mut c_void)> = let list_change: RcBlock<dyn Fn(u32, *mut c_void)> =
RcBlock::new(move |in_number_addresses, in_addresses: *mut c_void| { RcBlock::new(move |in_number_addresses, in_addresses: *mut c_void| {
@ -659,7 +516,7 @@ impl ShareableContent {
} }
let subscribers = { let subscribers = {
let mut map = HashMap::new(); let mut map = HashMap::new();
map.insert(id, callback); map.insert(id, callback_arc.clone());
map map
}; };
lock.insert(object_id, subscribers); lock.insert(object_id, subscribers);
@ -675,7 +532,7 @@ impl ShareableContent {
} }
#[napi] #[napi]
pub fn applications(&self) -> Result<Vec<TappableApplication>> { pub fn applications() -> Result<Vec<ApplicationInfo>> {
let app_list = RUNNING_APPLICATIONS let app_list = RUNNING_APPLICATIONS
.read() .read()
.map_err(|_| { .map_err(|_| {
@ -687,13 +544,44 @@ impl ShareableContent {
.iter() .iter()
.flatten() .flatten()
.filter_map(|id| { .filter_map(|id| {
let tappable_app = match TappableApplication::new(*id) { // Get process ID from object_id
Ok(app) => app, let process_id = get_process_property(id, kAudioProcessPropertyPID).unwrap_or(-1);
Err(_) => return None,
};
if !tappable_app.bundle_identifier().is_empty() { if process_id <= 0 {
Some(tappable_app) 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 { } else {
None None
} }
@ -704,7 +592,13 @@ impl ShareableContent {
} }
#[napi] #[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 // Get NSRunningApplication class
let running_app_class = NSRUNNING_APPLICATION_CLASS.as_ref()?; let running_app_class = NSRUNNING_APPLICATION_CLASS.as_ref()?;
@ -720,40 +614,107 @@ impl ShareableContent {
return None; return None;
} }
// Create an Application directly // Get application name
Application::new(process_id as i32).ok() 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(process_id: u32) -> Option<ApplicationInfo> {
pub fn tappable_application_with_process_id(
&self,
process_id: u32,
) -> Option<TappableApplication> {
// Find the TappableApplication with this process ID in the list of running // Find the TappableApplication with this process ID in the list of running
// applications // applications
match self.applications() { match ShareableContent::applications() {
Ok(apps) => { Ok(apps) => {
for app in apps { for app in apps {
if app.process_id() == process_id as i32 { if app.process_id == process_id as i32 {
return Some(app); return Some(app);
} }
} }
None
// 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,
}
} }
Err(_) => 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] #[napi]
pub fn tap_global_audio( pub fn tap_global_audio(
excluded_processes: Option<Vec<&TappableApplication>>, excluded_processes: Option<Vec<&ApplicationInfo>>,
audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, true>>, audio_stream_callback: ThreadsafeFunction<napi::bindgen_prelude::Float32Array, ()>,
) -> Result<AudioCaptureSession> { ) -> Result<AudioCaptureSession> {
let excluded_object_ids = excluded_processes let excluded_object_ids = excluded_processes
.unwrap_or_default() .unwrap_or_default()
@ -761,9 +722,12 @@ impl ShareableContent {
.map(|app| app.object_id) .map(|app| app.object_id)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
// Convert ThreadsafeFunction to Arc<ThreadsafeFunction>
let callback_arc = Arc::new(audio_stream_callback);
// Use the new AggregateDeviceManager for automatic device adaptation // Use the new AggregateDeviceManager for automatic device adaptation
let mut device_manager = AggregateDeviceManager::new_global(&excluded_object_ids)?; 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); let boxed_manager = Box::new(device_manager);
Ok(AudioCaptureSession::new(boxed_manager)) Ok(AudioCaptureSession::new(boxed_manager))
} }

View File

@ -24,9 +24,8 @@ use coreaudio::sys::{
AudioObjectRemovePropertyListenerBlock, AudioTimeStamp, OSStatus, AudioObjectRemovePropertyListenerBlock, AudioTimeStamp, OSStatus,
}; };
use napi::{ use napi::{
bindgen_prelude::Float32Array, bindgen_prelude::{Float32Array, Result, Status},
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
Result,
}; };
use napi_derive::napi; use napi_derive::napi;
use objc2::runtime::AnyObject; use objc2::runtime::AnyObject;
@ -38,7 +37,7 @@ use crate::{
device::get_device_uid, device::get_device_uid,
error::CoreAudioError, error::CoreAudioError,
queue::create_audio_tap_queue, queue::create_audio_tap_queue,
screen_capture_kit::TappableApplication, screen_capture_kit::ApplicationInfo,
utils::{cfstring_from_bytes_with_nul, get_global_main_property}, utils::{cfstring_from_bytes_with_nul, get_global_main_property},
}; };
@ -69,7 +68,7 @@ pub struct AggregateDevice {
} }
impl AggregateDevice { impl AggregateDevice {
pub fn new(app: &TappableApplication) -> Result<Self> { pub fn new(app: &ApplicationInfo) -> Result<Self> {
let object_id = app.object_id; let object_id = app.object_id;
let tap_description = CATapDescription::init_stereo_mixdown_of_processes(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 /// Implementation for the AggregateDevice to start processing audio
pub fn start( pub fn start(
&mut self, &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 // Add original_audio_stats to ensure consistent target rate
original_audio_stats: AudioStats, original_audio_stats: AudioStats,
) -> Result<AudioTapStream> { ) -> Result<AudioTapStream> {
@ -598,13 +597,13 @@ pub struct AggregateDeviceManager {
app_id: Option<AudioObjectID>, app_id: Option<AudioObjectID>,
excluded_processes: Vec<AudioObjectID>, excluded_processes: Vec<AudioObjectID>,
active_stream: Option<Arc<std::sync::Mutex<Option<AudioTapStream>>>>, 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>, original_audio_stats: Option<AudioStats>,
} }
impl AggregateDeviceManager { impl AggregateDeviceManager {
/// Creates a new AggregateDeviceManager for a specific application /// 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)?; let device = AggregateDevice::new(app)?;
Ok(Self { Ok(Self {
@ -638,7 +637,7 @@ impl AggregateDeviceManager {
/// This sets up the initial stream and listeners. /// This sets up the initial stream and listeners.
pub fn start_capture( pub fn start_capture(
&mut self, &mut self,
audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, true>>, audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, Status, true>>,
) -> Result<()> { ) -> Result<()> {
// Store the callback for potential device switch later // Store the callback for potential device switch later
self.audio_callback = Some(audio_stream_callback.clone()); self.audio_callback = Some(audio_stream_callback.clone());
@ -717,10 +716,12 @@ impl AggregateDeviceManager {
}; };
// Create a new device with updated default devices // Create a new device with updated default devices
let result: Result<AggregateDevice> = (|| { let result: Result<AggregateDevice> = {
if is_app_specific { if is_app_specific {
if let Some(id) = app_id { 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) AggregateDevice::new(&app)
} else { } else {
Err(CoreAudioError::CreateProcessTapFailed(0).into()) Err(CoreAudioError::CreateProcessTapFailed(0).into())
@ -728,7 +729,7 @@ impl AggregateDeviceManager {
} else { } else {
AggregateDevice::create_global_tap_but_exclude_processes(&excluded_processes) AggregateDevice::create_global_tap_but_exclude_processes(&excluded_processes)
} }
})(); };
// If we successfully created a new device, stop the old stream and start a new // If we successfully created a new device, stop the old stream and start a new
// one // one

View 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.

View File

@ -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),
})
}

View 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())
}
}

View File

@ -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)
}

View 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);
}
}

View File

@ -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));
}
});
}

View File

@ -25,7 +25,7 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@napi-rs/cli": "3.0.0-alpha.81", "@napi-rs/cli": "3.0.0-alpha.89",
"@napi-rs/whisper": "^0.0.4", "@napi-rs/whisper": "^0.0.4",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"ava": "^6.2.0", "ava": "^6.2.0",

View File

@ -802,7 +802,7 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@affine/native@workspace:packages/frontend/native" resolution: "@affine/native@workspace:packages/frontend/native"
dependencies: 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" "@napi-rs/whisper": "npm:^0.0.4"
"@types/node": "npm:^22.0.0" "@types/node": "npm:^22.0.0"
ava: "npm:^6.2.0" ava: "npm:^6.2.0"
@ -887,7 +887,7 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@affine/server-native@workspace:packages/backend/native" resolution: "@affine/server-native@workspace:packages/backend/native"
dependencies: dependencies:
"@napi-rs/cli": "npm:3.0.0-alpha.81" "@napi-rs/cli": "npm:3.0.0-alpha.89"
lib0: "npm:^0.2.99" lib0: "npm:^0.2.99"
tiktoken: "npm:^1.0.17" tiktoken: "npm:^1.0.17"
tinybench: "npm:^4.0.0" tinybench: "npm:^4.0.0"
@ -7941,9 +7941,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@napi-rs/cli@npm:3.0.0-alpha.81": "@napi-rs/cli@npm:3.0.0-alpha.89":
version: 3.0.0-alpha.81 version: 3.0.0-alpha.89
resolution: "@napi-rs/cli@npm:3.0.0-alpha.81" resolution: "@napi-rs/cli@npm:3.0.0-alpha.89"
dependencies: dependencies:
"@inquirer/prompts": "npm:^7.4.0" "@inquirer/prompts": "npm:^7.4.0"
"@napi-rs/cross-toolchain": "npm:^0.0.19" "@napi-rs/cross-toolchain": "npm:^0.0.19"
@ -7970,7 +7970,7 @@ __metadata:
bin: bin:
napi: ./dist/cli.js napi: ./dist/cli.js
napi-raw: ./cli.mjs napi-raw: ./cli.mjs
checksum: 10/1b086706f753141d3632dd49bfeb2539c1e67af7c362da937cbd0cbad1c8578cf088d2afedc3a86302fb77e3dc7784c096081dc1b4b9e1d1a3c6bffe6308a5ff checksum: 10/8ba4122d1bf42bf844c8304e374aa6f08a7a2804cf0d45d9a0007820076b1560cb9c8d78a91c5c3c0b8a10e474f9277fc5faab78bbe87643a2ff2027f2129b11
languageName: node languageName: node
linkType: hard linkType: hard
@ -16363,7 +16363,7 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@y-octo/node@workspace:packages/common/y-octo/node" resolution: "@y-octo/node@workspace:packages/common/y-octo/node"
dependencies: 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/node": "npm:^22.14.1"
"@types/prompts": "npm:^2.4.9" "@types/prompts": "npm:^2.4.9"
c8: "npm:^10.1.3" c8: "npm:^10.1.3"