diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6cacc1..79caa38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,3 +56,44 @@ jobs: - name: Svelte / TypeScript check run: bun run check + + windows: + runs-on: windows-latest + env: + CMAKE_POLICY_VERSION_MINIMUM: '3.5' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo build + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + - name: Install JS deps + run: bun install --frozen-lockfile + + - name: Cargo test + working-directory: src-tauri + run: cargo test + + - name: TS-RS bindings up to date + run: git diff --exit-code -- src/lib/modules/pipeline/generated + + - name: Build Windows application + working-directory: src-tauri + run: cargo build --bin splitwave + + - name: Svelte / TypeScript check + run: bun run check + + - name: Build unsigned NSIS installer + run: bun run tauri build --bundles nsis --no-sign diff --git a/NOTICE b/NOTICE index b205889..e25c274 100644 --- a/NOTICE +++ b/NOTICE @@ -146,7 +146,27 @@ licenses and are not distributed with Splitwave. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -7. Dependency License Gating +7. VB-Audio VB-CABLE (Windows virtual microphone) +----------------------------------------------- + +On Windows, Splitwave can install and manage the standard VB-CABLE virtual +audio driver from VB-Audio Software. VB-CABLE is a third-party donationware +component; it is not developed by Splitwave and is not covered by Splitwave's +MIT license. + + VB-CABLE origin: https://www.vb-cable.com + VB-CABLE information: https://vb-audio.com/Cable/ + Licensing terms: https://vb-audio.com/Services/licensing.htm + +Splitwave downloads only the standard VB-CABLE package from VB-Audio's official +HTTPS host after an explicit user action. The user-facing setup identifies +VB-CABLE as donationware and provides the official information page. VB-Audio's +licensing terms may require a separate distribution or volume license for +professional deployments. + + +----------------------------------------------------------------- +8. Dependency License Gating ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ The Rust dependency tree is checked in CI with `cargo-deny` against @@ -156,7 +176,7 @@ than reviewed after the fact. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -8. Other Notable Dependencies +9. Other Notable Dependencies ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ The following components are used under permissive licenses diff --git a/README.md b/README.md index 1304804..e06df94 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,16 @@ Requires Windows 10 version 2004 or newer (for per-app capture) and the (preinstalled on current Windows 10/11). Download the `.exe` installer from [Releases](https://github.com/Horuse/Splitwave/releases/latest) and run it. -Virtual audio devices are not available on Windows. +To expose a Splitwave output as a microphone: + +1. Open **Virtual devices** and choose **Install virtual microphone**. +2. Complete the VB-CABLE installer. +3. Restart Windows if Splitwave shows **Restart required**. +4. Select `CABLE Input` in a Speaker node. +5. Select `CABLE Output` as the microphone in the target app. + +VB-CABLE is third-party donationware; see its +[official page](https://vb-audio.com/Cable/) for licensing and support. ## Platform support @@ -56,7 +65,7 @@ Virtual audio devices are not available on Windows. | Device volume control | ✅ | ✅ | ✅ | | Recording: WAV / FLAC / AIFF / MP3 / Opus | ✅ | ✅ | ✅ | | Recording: AAC (M4A) | ✅ | ❌ | ❌ | -| Virtual audio devices | ✅ AudioServerPlugin | ✅ PipeWire null-sinks | ❌ (no user-mode driver model) | +| Virtual audio devices | ✅ AudioServerPlugin | ✅ PipeWire null-sinks | ✅ fixed VB-CABLE microphone | | Effects, metering, file playback | ✅ | ✅ | ✅ | | CLAP plugins | ✅ | ✅ | ✅ | | VST3 plugins | ✅ | ✅ X11 | ✅ | @@ -80,14 +89,14 @@ Virtual audio devices are not available on Windows. VST3 defines no Wayland embedding - **Presets & templates:** shared effect presets with factory defaults, plus ready-made pipeline templates in the create flow -- **Virtual devices:** create named virtual audio devices that appear system-wide. - Use them to capture loopback audio from any app or to feed processed audio into - apps that accept a microphone input (DAWs, Discord, etc.) +- **Virtual devices:** create named virtual audio devices that appear system-wide + on macOS and Linux. Windows uses the single fixed VB-Audio VB-CABLE pair as a + managed virtual microphone; it does not provide arbitrary named Windows devices. System and per-app capture use **ScreenCaptureKit** on macOS, **PipeWire** on Linux, and **WASAPI loopback** / the **Process Loopback API** on Windows. Virtual -devices are AudioServerPlugin drivers on macOS and PipeWire null-sinks on Linux; -Windows has no user-mode virtual-device model, so they are unavailable there. +devices are AudioServerPlugin drivers on macOS and PipeWire null-sinks on Linux. +Windows uses VB-Audio VB-CABLE through the existing WASAPI output path. ## Stack diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6585cf2..a2a9d78 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2358,8 +2358,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -2795,6 +2797,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots 1.0.9", ] [[package]] @@ -3527,6 +3530,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mach2" version = "0.4.3" @@ -4979,6 +4988,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -5086,6 +5151,15 @@ dependencies = [ "rand 0.8.6", ] +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -5207,6 +5281,46 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.9", +] + [[package]] name = "reqwest" version = "0.13.3" @@ -5483,6 +5597,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -5548,6 +5663,12 @@ dependencies = [ "unicode-script", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -5805,6 +5926,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" @@ -6116,12 +6249,14 @@ dependencies = [ "pipewire", "png 0.17.16", "raw-window-handle", + "reqwest 0.12.28", "resvg", "rtrb", "rubato 0.16.2", "rustls", "serde", "serde_json", + "sha2", "symphonia", "tauri", "tauri-build", @@ -6144,6 +6279,8 @@ dependencies = [ "webrtc", "windows 0.61.3", "windows-core 0.61.2", + "winreg 0.55.0", + "zip 2.4.2", ] [[package]] @@ -6660,7 +6797,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.3", "serde", "serde_json", "serde_repr", @@ -6912,7 +7049,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest", + "reqwest 0.13.3", "rustls", "semver", "serde", @@ -6926,7 +7063,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -9542,6 +9679,23 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zip" version = "4.6.1" @@ -9560,6 +9714,18 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 35bc41d..744c069 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,13 @@ opt-level = 3 [profile.dev.package.rustfft] opt-level = 3 +# audiopus_sys uses CMake. At opt-level 0 CMake selects the debug MSVC runtime, +# while Rust uses the release runtime even in dev/test profiles on Windows. +[profile.dev.package.audiopus_sys] +opt-level = 3 +[profile.test.package.audiopus_sys] +opt-level = 3 + # DeepFilterNet inference (tract) is far heavier than the FFT crates above. At # -O0 a single block's inference blows the DSP block deadline, the worker # stalls, and the voice gets glitchy. Optimise the tract stack + deep_filter in @@ -115,18 +122,26 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" [target.'cfg(target_os = "windows")'.dependencies] windows-core = "0.61" png = "0.17" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls-webpki-roots"] } +sha2 = "0.10" +winreg = "0.55" +zip = { version = "2.4", default-features = false, features = ["deflate"] } windows = { version = "0.61", features = [ "Win32_Foundation", "Win32_Security", + "Win32_Security_Authorization", "Win32_Media_Audio", "Win32_Media_Audio_Endpoints", "Win32_System_Com", "Win32_System_Com_StructuredStorage", + "Win32_System_Registry", "Win32_System_Variant", "Win32_System_Threading", "Win32_System_ProcessStatus", "Win32_Storage_FileSystem", + "Win32_Devices_DeviceAndDriverInstallation", "Win32_Devices_FunctionDiscovery", + "Win32_Devices_Properties", "Win32_UI_Shell", "Win32_UI_Shell_PropertiesSystem", "Win32_UI_WindowsAndMessaging", diff --git a/src-tauri/build.rs b/src-tauri/build.rs index b140f6e..5b33a93 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -10,9 +10,18 @@ fn main() { compile_swift_static_lib(); build_virtual_driver(); } + if target_os == "windows" { + enable_common_controls_manifest(); + } tauri_build::build() } +fn enable_common_controls_manifest() { + println!( + "cargo:rustc-link-arg=/manifestdependency:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'" + ); +} + fn compile_swift_static_lib() { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); let native_dir = manifest_dir.join("native"); diff --git a/src-tauri/src/audio/plugins/mod.rs b/src-tauri/src/audio/plugins/mod.rs index 6e6a99e..1003132 100644 --- a/src-tauri/src/audio/plugins/mod.rs +++ b/src-tauri/src/audio/plugins/mod.rs @@ -42,7 +42,6 @@ pub use scan::scan_all; #[ts(export)] pub enum PluginFormat { Clap, - #[cfg(target_os = "macos")] Au, Vst3, } diff --git a/src-tauri/src/audio/plugins/registry.rs b/src-tauri/src/audio/plugins/registry.rs index 7178b1d..c625742 100644 --- a/src-tauri/src/audio/plugins/registry.rs +++ b/src-tauri/src/audio/plugins/registry.rs @@ -18,13 +18,15 @@ use super::host_api::{ActivateRequest, HostedNode, PluginHost}; use super::vst3_registry::Vst3Host; use super::PluginFormat; -fn host_for(format: PluginFormat) -> &'static dyn PluginHost { - match format { +fn host_for(format: PluginFormat) -> Option<&'static dyn PluginHost> { + Some(match format { PluginFormat::Clap => &ClapHost, #[cfg(target_os = "macos")] PluginFormat::Au => &AuHost, + #[cfg(not(target_os = "macos"))] + PluginFormat::Au => return None, PluginFormat::Vst3 => &Vst3Host, - } + }) } fn owners() -> &'static Mutex> { @@ -45,7 +47,12 @@ pub fn hosts() -> impl Iterator { /// The host currently running this node, or `None` if it runs no plugin. pub fn for_node(node_id: &str) -> Option<&'static dyn PluginHost> { - owners().lock().unwrap().get(node_id).copied().map(host_for) + owners() + .lock() + .unwrap() + .get(node_id) + .copied() + .and_then(host_for) } /// Instantiates through the format's host, taking ownership of the node away @@ -58,11 +65,14 @@ pub fn activate(format: PluginFormat, req: ActivateRequest<'_>) -> Result) -> Result bool { + error.to_string().contains("0x80070490") + } + + #[cfg(not(target_os = "windows"))] + fn is_missing_audio_endpoint(_error: &crate::error::AppError) -> bool { + false + } #[test] fn enumerates_audio_apps_without_hanging() { - let apps = list_audio_applications().expect("apps"); - println!("found {} audio application(s):", apps.len()); - for a in &apps { - println!(" - {} ({})", a.name, a.bundle_id); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(list_audio_applications()); + }); + + match rx.recv_timeout(Duration::from_secs(5)) { + Ok(Ok(apps)) => { + println!("found {} audio application(s):", apps.len()); + for app in &apps { + println!(" - {} ({})", app.name, app.bundle_id); + } + } + Ok(Err(error)) if is_missing_audio_endpoint(&error) => { + println!("audio enumeration completed without a default endpoint: {error}"); + } + Ok(Err(error)) => panic!("audio enumeration failed: {error}"), + Err(mpsc::RecvTimeoutError::Timeout) => { + panic!("audio enumeration did not finish within 5 seconds") + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + panic!("audio enumeration thread stopped unexpectedly") + } } } } diff --git a/src-tauri/src/audio/virtual_device/mod.rs b/src-tauri/src/audio/virtual_device/mod.rs index bacc684..5ee1b3c 100644 --- a/src-tauri/src/audio/virtual_device/mod.rs +++ b/src-tauri/src/audio/virtual_device/mod.rs @@ -22,6 +22,12 @@ pub struct VirtualDriverStatus { pub needs_update: bool, } +pub mod windows_cable; +pub use windows_cable::{ + install_windows_virtual_cable, windows_virtual_cable_status, WindowsVirtualCableError, + WindowsVirtualCableStatus, +}; + #[cfg(target_os = "macos")] mod macos; #[cfg(target_os = "macos")] diff --git a/src-tauri/src/audio/virtual_device/windows_cable.rs b/src-tauri/src/audio/virtual_device/windows_cable.rs new file mode 100644 index 0000000..0b0e47d --- /dev/null +++ b/src-tauri/src/audio/virtual_device/windows_cable.rs @@ -0,0 +1,848 @@ +//! Managed integration for the single fixed VB-Audio VB-CABLE pair on Windows. +//! +//! This is deliberately separate from `VirtualDeviceConfig`: VB-CABLE cannot +//! implement Splitwave's arbitrary named-device semantics. + +use serde::{Deserialize, Serialize}; + +const MANIFEST_SCHEMA_VERSION: u32 = 1; +const PROVIDER_NAME: &str = "VB-Audio VB-CABLE"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WindowsVirtualCableState { + NotInstalled, + InstalledExternal, + InstalledManaged, + Partial, + RebootRequired, + RemovalPendingReboot, + UnknownOwnership, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WindowsVirtualCableOwnership { + External, + Managed, + Unknown, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowsVirtualCableStatus { + pub state: WindowsVirtualCableState, + pub usable: bool, + pub provider: String, + pub installed_version: Option, + pub render_endpoint_name: Option, + pub capture_endpoint_name: Option, + pub ownership: WindowsVirtualCableOwnership, + pub managed_by_splitwave: bool, + pub reboot_required: bool, + pub detail: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowsVirtualCableError { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub installer_exit_code: Option, +} + +impl WindowsVirtualCableError { + pub fn operation_failed(message: impl Into) -> Self { + Self::new("operationFailed", message) + } + + pub fn confirmation_required() -> Self { + Self::new( + "confirmationRequired", + "Confirm removal of the managed VB-CABLE driver first", + ) + } + + fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + installer_exit_code: None, + } + } + + fn with_installer_exit_code(mut self, installer_exit_code: Option) -> Self { + self.installer_exit_code = installer_exit_code; + self + } +} + +impl std::fmt::Display for WindowsVirtualCableError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for WindowsVirtualCableError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CablePackage { + provider: String, + version: Option, + published_name: String, + original_name: Option, + device_instance_ids: Vec, +} + +impl CablePackage { + fn fingerprint(&self) -> String { + let mut parts = self.device_instance_ids.clone(); + parts.sort(); + format!( + "{}|{}|{}|{}", + self.provider, + self.version.as_deref().unwrap_or_default(), + self.published_name, + parts.join(",") + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CableEndpointFlow { + Render, + Capture, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CableEndpoint { + flow: CableEndpointFlow, + name: String, + parent_instance_id: String, +} + +#[derive(Debug, Clone, Default)] +struct DetectedCable { + render_endpoint_name: Option, + capture_endpoint_name: Option, + packages: Vec, +} + +impl DetectedCable { + fn usable(&self) -> bool { + self.render_endpoint_name.is_some() && self.capture_endpoint_name.is_some() + } + + fn package(&self) -> Option<&CablePackage> { + (self.packages.len() == 1).then(|| &self.packages[0]) + } +} + +fn detect_cable_from_inventory( + packages: Vec, + endpoints: impl IntoIterator, +) -> DetectedCable { + let mut detected = DetectedCable { + packages, + ..Default::default() + }; + for endpoint in endpoints { + let belongs_to_cable = detected.packages.iter().any(|package| { + package + .device_instance_ids + .iter() + .any(|id| id.eq_ignore_ascii_case(&endpoint.parent_instance_id)) + }); + if !belongs_to_cable { + continue; + } + if !is_expected_endpoint(endpoint.flow, &endpoint.name) { + continue; + } + match endpoint.flow { + CableEndpointFlow::Render if detected.render_endpoint_name.is_none() => { + detected.render_endpoint_name = Some(endpoint.name); + } + CableEndpointFlow::Capture if detected.capture_endpoint_name.is_none() => { + detected.capture_endpoint_name = Some(endpoint.name); + } + _ => {} + } + } + detected +} + +fn is_expected_endpoint(flow: CableEndpointFlow, name: &str) -> bool { + let expected = match flow { + CableEndpointFlow::Render => "CABLE Input", + CableEndpointFlow::Capture => "CABLE Output", + }; + name.eq_ignore_ascii_case(expected) + || name + .get(..expected.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(expected)) + && name + .get(expected.len()..) + .is_some_and(|suffix| suffix.starts_with(" (") && suffix.ends_with(')')) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct OwnershipManifest { + schema_version: u32, + provider: String, + ownership: WindowsVirtualCableOwnership, + installed_at: String, + installed_version: Option, + installer_source: String, + installer_sha256: String, + installer_publisher: String, + package_published_names: Vec, + package_original_names: Vec, + package_fingerprint: String, + device_instance_ids: Vec, + consumer_installation_ids: Vec, + pending_reboot: bool, + #[serde(default)] + removal_pending_reboot: bool, +} + +impl OwnershipManifest { + fn matches(&self, package: &CablePackage) -> bool { + self.schema_version == MANIFEST_SCHEMA_VERSION + && self.ownership == WindowsVirtualCableOwnership::Managed + && self.provider == PROVIDER_NAME + && self.package_published_names.len() == 1 + && self.package_published_names[0].eq_ignore_ascii_case(&package.published_name) + && self.package_fingerprint == package.fingerprint() + } +} + +#[derive(Debug, Clone)] +enum ManifestState { + Missing, + Valid(OwnershipManifest), + Corrupt, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UninstallReason { + UserRemoval, + Upgrade, + Repair, + SilentRemoval, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RemovalAction { + Preserve, + ReleaseConsumer, + RetainAsExternal, + RemoveExactPackage, +} + +fn determine_state( + detected: &DetectedCable, + manifest: &ManifestState, + current_consumer: &str, +) -> WindowsVirtualCableState { + if let ManifestState::Valid(m) = manifest { + if m.removal_pending_reboot && (!detected.packages.is_empty() || detected.usable()) { + return WindowsVirtualCableState::RemovalPendingReboot; + } + } + let Some(package) = detected.package() else { + return if detected.packages.is_empty() && !detected.usable() { + WindowsVirtualCableState::NotInstalled + } else if detected.usable() { + WindowsVirtualCableState::UnknownOwnership + } else { + WindowsVirtualCableState::Partial + }; + }; + + if !detected.usable() { + return match manifest { + ManifestState::Valid(m) if m.matches(package) && m.pending_reboot => { + WindowsVirtualCableState::RebootRequired + } + _ => WindowsVirtualCableState::Partial, + }; + } + + match manifest { + ManifestState::Missing => WindowsVirtualCableState::InstalledExternal, + ManifestState::Corrupt => WindowsVirtualCableState::UnknownOwnership, + ManifestState::Valid(m) if m.ownership == WindowsVirtualCableOwnership::External => { + WindowsVirtualCableState::InstalledExternal + } + ManifestState::Valid(m) + if m.matches(package) + && m.consumer_installation_ids + .iter() + .any(|id| id == current_consumer) => + { + WindowsVirtualCableState::InstalledManaged + } + ManifestState::Valid(m) if m.matches(package) => WindowsVirtualCableState::InstalledManaged, + ManifestState::Valid(_) => WindowsVirtualCableState::UnknownOwnership, + } +} + +fn removal_decision( + state: WindowsVirtualCableState, + manifest: &ManifestState, + current_consumer: &str, + reason: UninstallReason, + user_choice: Option, +) -> RemovalAction { + if matches!( + reason, + UninstallReason::Upgrade + | UninstallReason::Repair + | UninstallReason::SilentRemoval + | UninstallReason::Unknown + ) { + return RemovalAction::Preserve; + } + let ManifestState::Valid(manifest) = manifest else { + return RemovalAction::Preserve; + }; + if state != WindowsVirtualCableState::InstalledManaged + || manifest.ownership != WindowsVirtualCableOwnership::Managed + || !manifest + .consumer_installation_ids + .iter() + .any(|id| id == current_consumer) + { + return RemovalAction::Preserve; + } + if manifest.consumer_installation_ids.len() > 1 { + return RemovalAction::ReleaseConsumer; + } + match user_choice { + Some(true) => RemovalAction::RemoveExactPackage, + Some(false) => RemovalAction::RetainAsExternal, + None => RemovalAction::Preserve, + } +} + +fn status_from( + detected: &DetectedCable, + manifest: &ManifestState, + current_consumer: &str, +) -> WindowsVirtualCableStatus { + let state = determine_state(detected, manifest, current_consumer); + let package = detected.package(); + let ownership = match state { + WindowsVirtualCableState::InstalledExternal => WindowsVirtualCableOwnership::External, + WindowsVirtualCableState::InstalledManaged | WindowsVirtualCableState::RebootRequired => { + WindowsVirtualCableOwnership::Managed + } + WindowsVirtualCableState::UnknownOwnership => WindowsVirtualCableOwnership::Unknown, + _ => manifest_ownership(manifest), + }; + let reboot_required = matches!( + state, + WindowsVirtualCableState::RebootRequired | WindowsVirtualCableState::RemovalPendingReboot + ); + let detail = match state { + WindowsVirtualCableState::NotInstalled => None, + WindowsVirtualCableState::Partial => { + Some("VB-CABLE was detected, but Windows has not exposed both endpoints".into()) + } + WindowsVirtualCableState::RebootRequired => Some( + "Installation completed; restart Windows before using the virtual microphone".into(), + ), + WindowsVirtualCableState::RemovalPendingReboot => { + Some("Driver removal is pending a Windows restart".into()) + } + WindowsVirtualCableState::UnknownOwnership => { + Some("Splitwave cannot safely determine who manages this VB-CABLE installation".into()) + } + _ => None, + }; + WindowsVirtualCableStatus { + state, + usable: detected.usable(), + provider: package + .map(|p| p.provider.clone()) + .unwrap_or_else(|| PROVIDER_NAME.into()), + installed_version: package.and_then(|p| p.version.clone()), + render_endpoint_name: detected.render_endpoint_name.clone(), + capture_endpoint_name: detected.capture_endpoint_name.clone(), + ownership, + managed_by_splitwave: ownership == WindowsVirtualCableOwnership::Managed, + reboot_required, + detail, + } +} + +fn manifest_ownership(manifest: &ManifestState) -> WindowsVirtualCableOwnership { + match manifest { + ManifestState::Valid(m) => m.ownership, + ManifestState::Corrupt => WindowsVirtualCableOwnership::Unknown, + ManifestState::Missing => WindowsVirtualCableOwnership::External, + } +} + +fn newly_installed_package<'a>( + before: &DetectedCable, + after: &'a DetectedCable, +) -> Option<&'a CablePackage> { + if before.usable() || !before.packages.is_empty() { + return None; + } + + let mut packages = after.packages.iter().filter(|candidate| { + !before.packages.iter().any(|old| { + old.published_name + .eq_ignore_ascii_case(&candidate.published_name) + }) + }); + let package = packages.next()?; + packages.next().is_none().then_some(package) +} + +fn verified_package_after_setup<'a>( + before: &DetectedCable, + after: &'a DetectedCable, + setup_exit_code: Option, +) -> Result<&'a CablePackage, WindowsVirtualCableError> { + newly_installed_package(before, after).ok_or_else(|| { + let error = match setup_exit_code { + Some(0) => WindowsVirtualCableError::new( + "driverPackageNotDetected", + "VB-CABLE installer finished, but Windows did not register the expected driver", + ), + Some(_) => WindowsVirtualCableError::new( + "driverPackageNotDetected", + "VB-CABLE installer exited without registering the expected driver", + ), + None => WindowsVirtualCableError::new( + "installerFailed", + "VB-CABLE setup ended before Splitwave could verify the driver installation", + ), + }; + error.with_installer_exit_code(setup_exit_code) + }) +} + +#[cfg(target_os = "windows")] +mod platform; + +#[cfg(target_os = "windows")] +pub use platform::{install, status}; + +#[cfg(target_os = "windows")] +pub fn run_helper() -> Option { + platform::run_helper() +} + +#[cfg(not(target_os = "windows"))] +pub fn status() -> Result { + Err(WindowsVirtualCableError::new( + "unsupportedPlatform", + "VB-CABLE integration is available only on Windows", + )) +} + +#[cfg(not(target_os = "windows"))] +pub fn install() -> Result { + status() +} + +#[cfg(not(target_os = "windows"))] +pub fn run_helper() -> Option { + None +} + +pub fn windows_virtual_cable_status() -> Result +{ + status() +} + +pub fn install_windows_virtual_cable() -> Result +{ + install() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn package() -> CablePackage { + CablePackage { + provider: PROVIDER_NAME.into(), + version: Some("1.0.0".into()), + published_name: "oem42.inf".into(), + original_name: Some("vbMmeCable64_win10.inf".into()), + device_instance_ids: vec!["ROOT\\VBCABLE\\0000".into()], + } + } + + fn detected() -> DetectedCable { + DetectedCable { + render_endpoint_name: Some("CABLE Input (VB-Audio Virtual Cable)".into()), + capture_endpoint_name: Some("CABLE Output (VB-Audio Virtual Cable)".into()), + packages: vec![package()], + } + } + + fn endpoint(flow: CableEndpointFlow, name: &str, parent_instance_id: &str) -> CableEndpoint { + CableEndpoint { + flow, + name: name.into(), + parent_instance_id: parent_instance_id.into(), + } + } + + fn managed(consumers: &[&str]) -> ManifestState { + let package = package(); + ManifestState::Valid(OwnershipManifest { + schema_version: MANIFEST_SCHEMA_VERSION, + provider: PROVIDER_NAME.into(), + ownership: WindowsVirtualCableOwnership::Managed, + installed_at: "2026-08-17T00:00:00Z".into(), + installed_version: package.version.clone(), + installer_source: + "https://download.vb-audio.com/Download_CABLE/VBCABLE_Driver_Pack45.zip".into(), + installer_sha256: "hash".into(), + installer_publisher: "BUREL VINCENT Entrepreneur individuel".into(), + package_published_names: vec![package.published_name.clone()], + package_original_names: vec![package.original_name.clone().unwrap()], + package_fingerprint: package.fingerprint(), + device_instance_ids: package.device_instance_ids.clone(), + consumer_installation_ids: consumers.iter().map(ToString::to_string).collect(), + pending_reboot: false, + removal_pending_reboot: false, + }) + } + + #[test] + fn absent_cable_is_not_installed() { + assert_eq!( + determine_state(&DetectedCable::default(), &ManifestState::Missing, "A"), + WindowsVirtualCableState::NotInstalled + ); + } + + #[test] + fn preexisting_cable_is_external() { + assert_eq!( + determine_state(&detected(), &ManifestState::Missing, "A"), + WindowsVirtualCableState::InstalledExternal + ); + } + + #[test] + fn external_record_is_never_claimed_as_managed() { + let ManifestState::Valid(mut manifest) = managed(&["A"]) else { + unreachable!() + }; + manifest.ownership = WindowsVirtualCableOwnership::External; + assert_eq!( + determine_state(&detected(), &ManifestState::Valid(manifest), "A"), + WindowsVirtualCableState::InstalledExternal + ); + } + + #[test] + fn corrupt_or_unknown_manifest_is_not_safe_to_remove() { + assert_eq!( + determine_state(&detected(), &ManifestState::Corrupt, "A"), + WindowsVirtualCableState::UnknownOwnership + ); + let ManifestState::Valid(mut manifest) = managed(&["A"]) else { + unreachable!() + }; + manifest.schema_version += 1; + assert_eq!( + determine_state(&detected(), &ManifestState::Valid(manifest), "A"), + WindowsVirtualCableState::UnknownOwnership + ); + } + + #[test] + fn managed_cable_requires_matching_package() { + assert_eq!( + determine_state(&detected(), &managed(&["A"]), "A"), + WindowsVirtualCableState::InstalledManaged + ); + let mut changed = detected(); + changed.packages[0].published_name = "oem99.inf".into(); + assert_eq!( + determine_state(&changed, &managed(&["A"]), "A"), + WindowsVirtualCableState::UnknownOwnership + ); + } + + #[test] + fn single_endpoint_is_partial() { + let mut only_render = detected(); + only_render.capture_endpoint_name = None; + assert_eq!( + determine_state(&only_render, &ManifestState::Missing, "A"), + WindowsVirtualCableState::Partial + ); + } + + #[test] + fn endpoint_name_without_matching_pnp_parent_is_not_a_cable() { + let cable = detect_cable_from_inventory( + vec![package()], + [ + endpoint( + CableEndpointFlow::Render, + "CABLE Input (VB-Audio Virtual Cable)", + "ROOT\\OTHER\\0000", + ), + endpoint( + CableEndpointFlow::Capture, + "CABLE Output (VB-Audio Virtual Cable)", + "ROOT\\OTHER\\0000", + ), + ], + ); + + assert_eq!( + determine_state(&cable, &ManifestState::Missing, "A"), + WindowsVirtualCableState::Partial + ); + assert!(!cable.usable()); + } + + #[test] + fn matched_pnp_parent_identifies_both_cable_endpoints() { + let cable = detect_cable_from_inventory( + vec![package()], + [ + endpoint( + CableEndpointFlow::Render, + "CABLE Input (VB-Audio Virtual Cable)", + "root\\vbcable\\0000", + ), + endpoint( + CableEndpointFlow::Capture, + "CABLE Output (VB-Audio Virtual Cable)", + "ROOT\\VBCABLE\\0000", + ), + ], + ); + + assert!(cable.usable()); + assert_eq!( + cable.render_endpoint_name.as_deref(), + Some("CABLE Input (VB-Audio Virtual Cable)") + ); + assert_eq!( + cable.capture_endpoint_name.as_deref(), + Some("CABLE Output (VB-Audio Virtual Cable)") + ); + } + + #[test] + fn multichannel_playback_endpoint_does_not_replace_cable_input() { + let cable = detect_cable_from_inventory( + vec![package()], + [ + endpoint( + CableEndpointFlow::Render, + "CABLE In 16ch (VB-Audio Virtual Cable)", + "ROOT\\VBCABLE\\0000", + ), + endpoint( + CableEndpointFlow::Render, + "CABLE Input (VB-Audio Virtual Cable)", + "ROOT\\VBCABLE\\0000", + ), + endpoint( + CableEndpointFlow::Capture, + "CABLE Output (VB-Audio Virtual Cable)", + "ROOT\\VBCABLE\\0000", + ), + ], + ); + + assert!(cable.usable()); + assert_eq!( + cable.render_endpoint_name.as_deref(), + Some("CABLE Input (VB-Audio Virtual Cable)") + ); + } + + #[test] + fn reboot_pending_package_is_not_ready() { + let mut cable = detected(); + cable.capture_endpoint_name = None; + let ManifestState::Valid(mut manifest) = managed(&["A"]) else { + unreachable!() + }; + manifest.pending_reboot = true; + assert_eq!( + determine_state(&cable, &ManifestState::Valid(manifest), "A"), + WindowsVirtualCableState::RebootRequired + ); + } + + #[test] + fn pending_removal_is_reported_until_windows_restarts() { + let ManifestState::Valid(mut manifest) = managed(&["A"]) else { + unreachable!() + }; + manifest.removal_pending_reboot = true; + assert_eq!( + determine_state(&detected(), &ManifestState::Valid(manifest), "A"), + WindowsVirtualCableState::RemovalPendingReboot + ); + } + + #[test] + fn external_and_unknown_ownership_are_never_removed() { + assert_eq!( + removal_decision( + WindowsVirtualCableState::InstalledExternal, + &ManifestState::Missing, + "A", + UninstallReason::UserRemoval, + Some(true) + ), + RemovalAction::Preserve + ); + assert_eq!( + removal_decision( + WindowsVirtualCableState::UnknownOwnership, + &ManifestState::Corrupt, + "A", + UninstallReason::UserRemoval, + Some(true) + ), + RemovalAction::Preserve + ); + } + + #[test] + fn multiple_consumers_release_without_removing_driver() { + assert_eq!( + removal_decision( + WindowsVirtualCableState::InstalledManaged, + &managed(&["A", "B"]), + "A", + UninstallReason::UserRemoval, + Some(true) + ), + RemovalAction::ReleaseConsumer + ); + } + + #[test] + fn last_consumer_default_keeps_driver_external() { + assert_eq!( + removal_decision( + WindowsVirtualCableState::InstalledManaged, + &managed(&["A"]), + "A", + UninstallReason::UserRemoval, + Some(false) + ), + RemovalAction::RetainAsExternal + ); + } + + #[test] + fn retained_cable_is_external_for_future_installs() { + let ManifestState::Valid(mut manifest) = managed(&["A"]) else { + unreachable!() + }; + manifest.ownership = WindowsVirtualCableOwnership::External; + manifest.consumer_installation_ids.clear(); + assert_eq!( + determine_state(&detected(), &ManifestState::Valid(manifest), "B"), + WindowsVirtualCableState::InstalledExternal + ); + } + + #[test] + fn last_consumer_can_remove_only_after_yes() { + assert_eq!( + removal_decision( + WindowsVirtualCableState::InstalledManaged, + &managed(&["A"]), + "A", + UninstallReason::UserRemoval, + Some(true) + ), + RemovalAction::RemoveExactPackage + ); + } + + #[test] + fn missing_exact_package_is_never_broadly_removed() { + assert_eq!( + removal_decision( + WindowsVirtualCableState::NotInstalled, + &managed(&["A"]), + "A", + UninstallReason::UserRemoval, + Some(true) + ), + RemovalAction::Preserve + ); + } + + #[test] + fn existing_cable_is_never_considered_a_new_managed_package() { + let package = package(); + let before = DetectedCable { + render_endpoint_name: Some("CABLE Input".into()), + capture_endpoint_name: Some("CABLE Output".into()), + packages: vec![package], + }; + + assert!(newly_installed_package(&before, &before).is_none()); + } + + #[test] + fn nonzero_setup_exit_is_accepted_after_exact_package_installation() { + let before = DetectedCable::default(); + let after = detected(); + assert_eq!( + verified_package_after_setup(&before, &after, Some(1)) + .expect("the exact new package proves installation") + .published_name, + "oem42.inf" + ); + } + + #[test] + fn nonzero_setup_exit_without_new_package_preserves_the_specific_failure() { + let before = DetectedCable::default(); + let error = verified_package_after_setup(&before, &DetectedCable::default(), Some(1)) + .expect_err("no package was installed"); + assert_eq!(error.code, "driverPackageNotDetected"); + assert_eq!(error.installer_exit_code, Some(1)); + } + + #[test] + fn upgrade_repair_silent_and_unknown_preserve_driver() { + for reason in [ + UninstallReason::Upgrade, + UninstallReason::Repair, + UninstallReason::SilentRemoval, + UninstallReason::Unknown, + ] { + assert_eq!( + removal_decision( + WindowsVirtualCableState::InstalledManaged, + &managed(&["A"]), + "A", + reason, + Some(true) + ), + RemovalAction::Preserve + ); + } + } +} diff --git a/src-tauri/src/audio/virtual_device/windows_cable/platform.rs b/src-tauri/src/audio/virtual_device/windows_cable/platform.rs new file mode 100644 index 0000000..9d882fb --- /dev/null +++ b/src-tauri/src/audio/virtual_device/windows_cable/platform.rs @@ -0,0 +1,1865 @@ +use std::collections::HashMap; +use std::ffi::{c_void, OsString}; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::os::windows::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tracing::{info, warn}; +use windows::core::{PCWSTR, PWSTR}; +use windows::Win32::Devices::DeviceAndDriverInstallation::{ + SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInfo, SetupDiGetClassDevsW, + SetupDiGetDeviceInstanceIdW, SetupDiGetDevicePropertyW, DIGCF_ALLCLASSES, SP_DEVINFO_DATA, +}; +use windows::Win32::Devices::FunctionDiscovery::PKEY_Device_FriendlyName; +use windows::Win32::Devices::Properties::{ + DEVPKEY_Device_DeviceDesc, DEVPKEY_Device_DriverInfPath, DEVPKEY_Device_DriverProvider, + DEVPKEY_Device_DriverVersion, DEVPKEY_Device_FriendlyName, DEVPKEY_Device_Parent, +}; +use windows::Win32::Foundation::{ + CloseHandle, LocalFree, ERROR_CANCELLED, ERROR_NO_MORE_ITEMS, HANDLE, HLOCAL, +}; +use windows::Win32::Media::Audio::{ + eCapture, eRender, IMMDevice, IMMDeviceEnumerator, MMDeviceEnumerator, DEVICE_STATE_ACTIVE, +}; +use windows::Win32::Security::Authorization::ConvertSidToStringSidW; +use windows::Win32::Security::{ + GetTokenInformation, TokenElevation, TokenUser, TOKEN_ELEVATION, TOKEN_QUERY, TOKEN_USER, +}; +use windows::Win32::System::Com::StructuredStorage::PropVariantClear; +use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoTaskMemFree, CLSCTX_ALL, COINIT_MULTITHREADED, STGM_READ, +}; +use windows::Win32::System::Threading::{ + GetCurrentProcess, GetExitCodeProcess, OpenProcessToken, WaitForSingleObject, INFINITE, +}; +use windows::Win32::UI::Shell::{ShellExecuteExW, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW}; +use winreg::enums::{HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_64KEY, KEY_WRITE}; +use winreg::RegKey; +use zip::ZipArchive; + +use super::{ + detect_cable_from_inventory, determine_state, manifest_ownership, removal_decision, + status_from, verified_package_after_setup, CableEndpoint, CableEndpointFlow, CablePackage, + DetectedCable, ManifestState, OwnershipManifest, RemovalAction, UninstallReason, + WindowsVirtualCableError, WindowsVirtualCableOwnership, WindowsVirtualCableStatus, + MANIFEST_SCHEMA_VERSION, PROVIDER_NAME, +}; + +const VBCABLE_URL: &str = "https://download.vb-audio.com/Download_CABLE/VBCABLE_Driver_Pack45.zip"; +const VBCABLE_ARCHIVE_SHA256: &str = + "B950E39F01AF1D04EA623C8F6D8EB9B6EA5C477C637295FABF20631C85116BFB"; +const VBCABLE_SIGNER_SUBJECT: &str = "BUREL VINCENT Entrepreneur individuel"; +const VBCABLE_MAX_ARCHIVE_BYTES: u64 = 5 * 1024 * 1024; +const VBCABLE_MAX_ARCHIVE_FILES: usize = 64; +const VBCABLE_MAX_EXTRACTED_BYTES: u64 = 16 * 1024 * 1024; +const REGISTRY_PATH: &str = r"SOFTWARE\Horuse\Splitwave\Dependencies\VBCable"; +const REGISTRY_VALUE: &str = "Manifest"; +const HELPER_FLAG: &str = "--vb-cable-helper"; +const HELPER_RESULT_PATH_FLAG: &str = "--result-path"; +const HELPER_REQUEST_ID_FLAG: &str = "--request-id"; +const HELPER_RESULT_MAX_BYTES: u64 = 16 * 1024; +const EXIT_REQUIRES_CONFIRMATION: u32 = 20; +const EXIT_REBOOT_REQUIRED: u32 = 21; + +static OPERATION_IN_PROGRESS: AtomicBool = AtomicBool::new(false); + +struct OperationGuard; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct HelperResult { + request_id: String, + success: bool, + code: String, + message: String, + installer_exit_code: Option, + render_endpoint_name: Option, + capture_endpoint_name: Option, + published_inf: Option, + ownership: WindowsVirtualCableOwnership, +} + +struct HelperResultTarget { + path: PathBuf, + request_id: String, +} + +struct HelperResultChannel { + _directory: TemporaryPath, + path: PathBuf, + request_id: String, +} + +impl OperationGuard { + fn acquire() -> Result { + OPERATION_IN_PROGRESS + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .map_err(|_| { + WindowsVirtualCableError::new( + "operationInProgress", + "A VB-CABLE operation is already running", + ) + })?; + Ok(Self) + } +} + +impl Drop for OperationGuard { + fn drop(&mut self) { + OPERATION_IN_PROGRESS.store(false, Ordering::Release); + } +} + +pub fn status() -> Result { + let detected = detect_cable()?; + let manifest = read_manifest(); + Ok(status_from( + &detected, + &manifest, + &consumer_id(¤t_install_root()?)?, + )) +} + +pub fn install() -> Result { + let _guard = OperationGuard::acquire()?; + let consumer = consumer_id(¤t_install_root()?)?; + let archive = download_archive()?; + let result_channel = HelperResultChannel::create()?; + let exit_code = elevate_current(&[ + OsString::from(HELPER_FLAG), + OsString::from("install"), + archive.path().as_os_str().to_owned(), + OsString::from(consumer), + OsString::from(HELPER_RESULT_PATH_FLAG), + result_channel.path.as_os_str().to_owned(), + OsString::from(HELPER_REQUEST_ID_FLAG), + OsString::from(&result_channel.request_id), + ])?; + let helper_result = result_channel.read(exit_code).map_err(|error| { + warn!( + stage = "install", + helper_exit_code = exit_code, + code = %error.code, + message = %error.message, + "VB-CABLE elevated helper result could not be read" + ); + error + })?; + info!( + stage = "install", + helper_exit_code = exit_code, + code = %helper_result.code, + message = %helper_result.message, + installer_exit_code = ?helper_result.installer_exit_code, + render_endpoint = ?helper_result.render_endpoint_name, + capture_endpoint = ?helper_result.capture_endpoint_name, + published_inf = ?helper_result.published_inf, + ownership = ?helper_result.ownership, + "VB-CABLE elevated helper completed" + ); + if helper_result.success { + status() + } else { + Err(helper_result.into_error()) + } +} + +pub fn run_helper() -> Option { + let args: Vec = std::env::args_os().collect(); + if args.get(1).and_then(|v| v.to_str()) != Some(HELPER_FLAG) { + return None; + } + let action = args.get(2).and_then(|v| v.to_str()); + let root = || { + args.get(3).map(PathBuf::from).ok_or_else(|| { + WindowsVirtualCableError::new( + "invalidHelperArguments", + "Missing installation directory", + ) + }) + }; + let result_target = helper_result_target(&args); + let mut installer_exit_code = None; + let result = match &result_target { + Err(error) => Err(error.clone()), + Ok(_) => match action { + Some("install") => args + .get(3) + .map(PathBuf::from) + .ok_or_else(|| { + WindowsVirtualCableError::new( + "invalidHelperArguments", + "Missing VB-CABLE archive path", + ) + }) + .and_then(|archive| { + let consumer = supplied_consumer_id(&args)?; + ensure_elevated_or_rerun(&args, || { + installer_exit_code = helper_install(&archive, &consumer)?; + Ok(()) + }) + }), + Some("register-consumer") => { + root().and_then(|root| register_consumer_with_elevation(&args, &root)) + } + Some("unregister") => root().and_then(|root| unregister_consumer(&args, &root)), + Some("retain") => { + root().and_then(|root| retain_as_external_with_elevation(&args, &root)) + } + Some("remove") => { + root().and_then(|root| remove_exact_package_with_elevation(&args, &root)) + } + _ => Err(WindowsVirtualCableError::new( + "invalidHelperArguments", + "Unknown VB-CABLE helper action", + )), + }, + }; + let exit_code = match &result { + Ok(()) => 0, + Err(error) if error.code == "rebootRequired" => EXIT_REBOOT_REQUIRED, + Err(error) if error.code == "confirmationRequired" => EXIT_REQUIRES_CONFIRMATION, + Err(error) => { + warn!(code = %error.code, message = %error.message, "VB-CABLE helper failed"); + 1 + } + }; + if let Ok(Some(target)) = result_target { + let helper_result = + HelperResult::from_operation(target.request_id, &result, installer_exit_code); + if let Err(error) = write_helper_result(&target.path, &helper_result) { + warn!(code = %error.code, message = %error.message, "VB-CABLE helper could not write its result"); + return Some(1); + } + } + Some( + exit_code + .try_into() + .expect("fixed helper exit codes fit in i32"), + ) +} + +fn ensure_elevated_or_rerun( + args: &[OsString], + operation: impl FnOnce() -> Result<(), WindowsVirtualCableError>, +) -> Result<(), WindowsVirtualCableError> { + if is_elevated()? { + operation() + } else { + let code = elevate_current(&args[1..])?; + if code == 0 || code == EXIT_REBOOT_REQUIRED { + Ok(()) + } else { + Err(helper_error("elevated helper", code)) + } + } +} + +fn unregister_consumer(args: &[OsString], root: &Path) -> Result<(), WindowsVirtualCableError> { + let detected = detect_cable()?; + let manifest = read_manifest(); + let consumer = helper_consumer_id(args, root)?; + let state = determine_state(&detected, &manifest, &consumer); + // Treat the uninstaller's pending response as "keep" so a sole consumer + // receives the explicit NSIS prompt instead of silently dropping ownership. + let action = removal_decision( + state, + &manifest, + &consumer, + UninstallReason::UserRemoval, + Some(false), + ); + match action { + RemovalAction::Preserve => Ok(()), + RemovalAction::ReleaseConsumer => { + ensure_elevated_or_rerun(&args_with_consumer(args, &consumer), || { + release_consumer(&consumer) + }) + } + RemovalAction::RetainAsExternal => Err(WindowsVirtualCableError::confirmation_required()), + RemovalAction::RemoveExactPackage => Err(WindowsVirtualCableError::new( + "ownershipConflict", + "Unexpected VB-CABLE uninstaller state", + )), + } +} + +fn helper_install(archive: &Path, consumer: &str) -> Result, WindowsVirtualCableError> { + let before = detect_cable()?; + if before.usable() || !before.packages.is_empty() { + return Err(WindowsVirtualCableError::new( + "ownershipConflict", + "VB-CABLE is already present and will not be claimed by Splitwave", + )); + } + let copied = copy_verified_archive(archive)?; + let extracted = extract_verified_archive(copied.path())?; + let installer = extracted.path().join(setup_program_name()); + verify_authenticode(&installer)?; + + info!("starting the visible VB-CABLE vendor installer"); + let result = Command::new(&installer).status().map_err(|e| { + WindowsVirtualCableError::new( + "installerFailed", + format!("Could not launch VB-CABLE setup: {e}"), + ) + })?; + let after = detect_cable().map_err(|error| error.with_installer_exit_code(result.code()))?; + let package = verified_package_after_setup(&before, &after, result.code())?.clone(); + if !result.success() { + warn!( + exit_code = result.code().unwrap_or(-1), + package = %package.published_name, + "VB-CABLE setup returned a nonzero code after installing the verified package" + ); + } + if !is_exact_package_name(&package.published_name) { + return Err(WindowsVirtualCableError::new( + "driverPackageNotFound", + "VB-CABLE setup did not expose a safe published driver package name", + ) + .with_installer_exit_code(result.code())); + } + + let manifest = OwnershipManifest { + schema_version: MANIFEST_SCHEMA_VERSION, + provider: PROVIDER_NAME.into(), + ownership: WindowsVirtualCableOwnership::Managed, + installed_at: now_unix_seconds(), + installed_version: package.version.clone(), + installer_source: VBCABLE_URL.into(), + installer_sha256: VBCABLE_ARCHIVE_SHA256.into(), + installer_publisher: VBCABLE_SIGNER_SUBJECT.into(), + package_published_names: vec![package.published_name.clone()], + package_original_names: package.original_name.clone().into_iter().collect(), + package_fingerprint: package.fingerprint(), + device_instance_ids: package.device_instance_ids.clone(), + consumer_installation_ids: vec![consumer.into()], + // VB-Audio's current reference manual requires a restart after installation. + pending_reboot: !after.usable(), + removal_pending_reboot: false, + }; + write_manifest(&manifest).map_err(|error| error.with_installer_exit_code(result.code()))?; + if !after.usable() { + return Err(WindowsVirtualCableError::new("rebootRequired", "VB-CABLE was installed, but Windows must be restarted before both endpoints are available").with_installer_exit_code(result.code())); + } + Ok(result.code()) +} + +fn register_consumer(consumer: &str) -> Result<(), WindowsVirtualCableError> { + let detected = detect_cable()?; + let ManifestState::Valid(mut manifest) = read_manifest() else { + return Ok(()); + }; + let Some(package) = detected.package() else { + return Ok(()); + }; + if !manifest.matches(package) { + return Ok(()); + } + if !manifest + .consumer_installation_ids + .iter() + .any(|id| id == consumer) + { + manifest.consumer_installation_ids.push(consumer.into()); + write_manifest(&manifest)?; + } + Ok(()) +} + +fn register_consumer_with_elevation( + args: &[OsString], + root: &Path, +) -> Result<(), WindowsVirtualCableError> { + let detected = detect_cable()?; + let ManifestState::Valid(manifest) = read_manifest() else { + return Ok(()); + }; + let Some(package) = detected.package() else { + return Ok(()); + }; + let consumer = helper_consumer_id(args, root)?; + if !manifest.matches(package) + || manifest + .consumer_installation_ids + .iter() + .any(|id| id == &consumer) + { + return Ok(()); + } + ensure_elevated_or_rerun(&args_with_consumer(args, &consumer), || { + register_consumer(&consumer) + }) +} + +fn release_consumer(consumer: &str) -> Result<(), WindowsVirtualCableError> { + let ManifestState::Valid(mut manifest) = read_manifest() else { + return Ok(()); + }; + manifest + .consumer_installation_ids + .retain(|id| id != consumer); + write_manifest(&manifest) +} + +fn retain_as_external(consumer: &str) -> Result<(), WindowsVirtualCableError> { + let detected = detect_cable()?; + let ManifestState::Valid(mut manifest) = read_manifest() else { + return Ok(()); + }; + let state = determine_state(&detected, &ManifestState::Valid(manifest.clone()), consumer); + if removal_decision( + state, + &ManifestState::Valid(manifest.clone()), + consumer, + UninstallReason::UserRemoval, + Some(false), + ) != RemovalAction::RetainAsExternal + { + return Err(WindowsVirtualCableError::new( + "ownershipConflict", + "VB-CABLE ownership changed before Splitwave could retain it", + )); + } + manifest.consumer_installation_ids.clear(); + manifest.ownership = WindowsVirtualCableOwnership::External; + manifest.pending_reboot = false; + write_manifest(&manifest) +} + +fn retain_as_external_with_elevation( + args: &[OsString], + root: &Path, +) -> Result<(), WindowsVirtualCableError> { + let consumer = helper_consumer_id(args, root)?; + ensure_elevated_or_rerun(&args_with_consumer(args, &consumer), || { + retain_as_external(&consumer) + }) +} + +fn remove_exact_package_with_elevation( + args: &[OsString], + root: &Path, +) -> Result<(), WindowsVirtualCableError> { + let consumer = helper_consumer_id(args, root)?; + ensure_elevated_or_rerun(&args_with_consumer(args, &consumer), || { + remove_exact_package(&consumer) + }) +} + +fn remove_exact_package(consumer: &str) -> Result<(), WindowsVirtualCableError> { + let detected = detect_cable()?; + let ManifestState::Valid(mut manifest) = read_manifest() else { + return Err(WindowsVirtualCableError::new( + "ownershipConflict", + "No managed VB-CABLE ownership record exists", + )); + }; + let state = determine_state(&detected, &ManifestState::Valid(manifest.clone()), consumer); + if removal_decision( + state, + &ManifestState::Valid(manifest.clone()), + consumer, + UninstallReason::UserRemoval, + Some(true), + ) != RemovalAction::RemoveExactPackage + { + return Err(WindowsVirtualCableError::new( + "ownershipConflict", + "VB-CABLE no longer matches Splitwave's managed package record", + )); + } + let published = manifest.package_published_names.first().ok_or_else(|| { + WindowsVirtualCableError::new( + "driverPackageNotFound", + "Managed VB-CABLE record has no published INF name", + ) + })?; + if !is_exact_package_name(published) { + return Err(WindowsVirtualCableError::new( + "packageFingerprintMismatch", + "Managed VB-CABLE record contains an unsafe package name", + )); + } + let status = Command::new("pnputil.exe") + .args(["/delete-driver", published, "/uninstall"]) + .status() + .map_err(|e| { + WindowsVirtualCableError::new("removalFailed", format!("Could not start PnPUtil: {e}")) + })?; + match status.code() { + Some(0) => { + let after = detect_cable()?; + if after + .packages + .iter() + .any(|p| p.published_name.eq_ignore_ascii_case(published)) + { + manifest.removal_pending_reboot = true; + write_manifest(&manifest)?; + Err(WindowsVirtualCableError::new( + "rebootRequired", + "VB-CABLE removal is pending a Windows restart", + )) + } else { + delete_manifest()?; + Ok(()) + } + } + Some(3010) => { + manifest.removal_pending_reboot = true; + write_manifest(&manifest)?; + Err(WindowsVirtualCableError::new( + "rebootRequired", + "VB-CABLE removal is pending a Windows restart", + )) + } + Some(code) => Err(WindowsVirtualCableError::new( + "removalFailed", + format!("PnPUtil exited with {code}"), + )), + None => Err(WindowsVirtualCableError::new( + "removalFailed", + "PnPUtil terminated unexpectedly", + )), + } +} + +fn detect_cable() -> Result { + let packages = enumerate_vb_cable_packages()?; + let endpoint_parents = enumerate_audio_endpoint_parents()?; + let endpoints = enumerate_active_audio_endpoints(&endpoint_parents)?; + Ok(detect_cable_from_inventory(packages, endpoints)) +} + +fn ensure_com() { + unsafe { + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + } +} + +fn enumerate_audio_endpoint_parents() -> Result, WindowsVirtualCableError> { + let set = unsafe { SetupDiGetClassDevsW(None, PCWSTR::null(), None, DIGCF_ALLCLASSES) } + .map_err(|e| { + WindowsVirtualCableError::new( + "deviceEnumerationFailed", + format!("SetupAPI device list: {e}"), + ) + })?; + let _set = DeviceSet(set); + let mut parents = HashMap::new(); + for index in 0.. { + let mut data = SP_DEVINFO_DATA { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if let Err(error) = unsafe { SetupDiEnumDeviceInfo(set, index, &mut data) } { + if (error.code().0 as u32 & 0xffff) == ERROR_NO_MORE_ITEMS.0 { + break; + } + return Err(WindowsVirtualCableError::new( + "deviceEnumerationFailed", + format!("SetupAPI enumerate: {error}"), + )); + } + let Some(instance_id) = device_instance_id(set, &data) else { + continue; + }; + if !instance_id + .to_ascii_lowercase() + .starts_with("swd\\mmdevapi\\") + { + continue; + } + let Some(parent) = property_string(set, &data, &DEVPKEY_Device_Parent) else { + continue; + }; + parents.insert(instance_id.to_ascii_lowercase(), parent); + } + Ok(parents) +} + +fn enumerate_active_audio_endpoints( + endpoint_parents: &HashMap, +) -> Result, WindowsVirtualCableError> { + ensure_com(); + let enumerator: IMMDeviceEnumerator = + unsafe { CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL) }.map_err(|e| { + WindowsVirtualCableError::new( + "deviceEnumerationFailed", + format!("MMDevice enumerator: {e}"), + ) + })?; + let mut endpoints = Vec::new(); + for (flow, endpoint_flow) in [ + (eRender, CableEndpointFlow::Render), + (eCapture, CableEndpointFlow::Capture), + ] { + let collection = unsafe { enumerator.EnumAudioEndpoints(flow, DEVICE_STATE_ACTIVE) } + .map_err(|e| { + WindowsVirtualCableError::new( + "deviceEnumerationFailed", + format!("MMDevice endpoint collection: {e}"), + ) + })?; + let count = unsafe { collection.GetCount() }.map_err(|e| { + WindowsVirtualCableError::new( + "deviceEnumerationFailed", + format!("MMDevice endpoint count: {e}"), + ) + })?; + for index in 0..count { + let device = unsafe { collection.Item(index) }.map_err(|e| { + WindowsVirtualCableError::new( + "deviceEnumerationFailed", + format!("MMDevice endpoint: {e}"), + ) + })?; + let Some(endpoint_id) = endpoint_id(&device) else { + continue; + }; + let pnp_id = format!("SWD\\MMDEVAPI\\{endpoint_id}").to_ascii_lowercase(); + let Some(parent_instance_id) = endpoint_parents.get(&pnp_id) else { + continue; + }; + let Some(name) = endpoint_name(&device) else { + continue; + }; + endpoints.push(CableEndpoint { + flow: endpoint_flow, + name, + parent_instance_id: parent_instance_id.clone(), + }); + } + } + Ok(endpoints) +} + +fn endpoint_id(device: &IMMDevice) -> Option { + let id = unsafe { device.GetId().ok()? }; + let value = unsafe { id.to_string().ok() }; + unsafe { + CoTaskMemFree(Some(id.0.cast())); + } + value +} + +fn endpoint_name(device: &IMMDevice) -> Option { + let store = unsafe { device.OpenPropertyStore(STGM_READ).ok()? }; + let mut prop = unsafe { store.GetValue(&PKEY_Device_FriendlyName).ok()? }; + let name = unsafe { prop.Anonymous.Anonymous.Anonymous.pwszVal.to_string().ok() } + .filter(|name| !name.is_empty()); + unsafe { + let _ = PropVariantClear(&mut prop); + } + name +} + +fn enumerate_vb_cable_packages() -> Result, WindowsVirtualCableError> { + let set = unsafe { SetupDiGetClassDevsW(None, PCWSTR::null(), None, DIGCF_ALLCLASSES) } + .map_err(|e| { + WindowsVirtualCableError::new( + "deviceEnumerationFailed", + format!("SetupAPI device list: {e}"), + ) + })?; + let _set = DeviceSet(set); + let mut packages: Vec = Vec::new(); + for index in 0.. { + let mut data = SP_DEVINFO_DATA { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if let Err(error) = unsafe { SetupDiEnumDeviceInfo(set, index, &mut data) } { + if (error.code().0 as u32 & 0xffff) == ERROR_NO_MORE_ITEMS.0 { + break; + } + return Err(WindowsVirtualCableError::new( + "deviceEnumerationFailed", + format!("SetupAPI enumerate: {error}"), + )); + } + let provider = property_string(set, &data, &DEVPKEY_Device_DriverProvider); + let published_name = property_string(set, &data, &DEVPKEY_Device_DriverInfPath); + // Root media devices do not consistently expose FriendlyName through + // SetupAPI. DeviceDesc carries the same signed-driver identity on those + // systems, so use it only as a fallback and retain the provider/package + // checks below. This still excludes VB-Audio's Voicemeeter packages. + let device_name = property_string(set, &data, &DEVPKEY_Device_FriendlyName) + .or_else(|| property_string(set, &data, &DEVPKEY_Device_DeviceDesc)); + let (Some(provider), Some(published_name), Some(device_name)) = + (provider, published_name, device_name) + else { + continue; + }; + if !is_vb_audio_provider(&provider) + || !is_vb_cable_friendly_name(&device_name) + || !is_exact_package_name(&published_name) + { + continue; + } + let version = property_string(set, &data, &DEVPKEY_Device_DriverVersion); + let instance_id = device_instance_id(set, &data).unwrap_or_default(); + if let Some(existing) = packages + .iter_mut() + .find(|p| p.published_name.eq_ignore_ascii_case(&published_name)) + { + if !instance_id.is_empty() && !existing.device_instance_ids.contains(&instance_id) { + existing.device_instance_ids.push(instance_id); + } + } else { + packages.push(CablePackage { + provider, + version, + published_name, + original_name: None, + device_instance_ids: (!instance_id.is_empty()) + .then_some(instance_id) + .into_iter() + .collect(), + }); + } + } + Ok(packages) +} + +struct DeviceSet(windows::Win32::Devices::DeviceAndDriverInstallation::HDEVINFO); + +impl Drop for DeviceSet { + fn drop(&mut self) { + unsafe { + let _ = SetupDiDestroyDeviceInfoList(self.0); + } + } +} + +fn property_string( + set: windows::Win32::Devices::DeviceAndDriverInstallation::HDEVINFO, + data: &SP_DEVINFO_DATA, + key: &windows::Win32::Foundation::DEVPROPKEY, +) -> Option { + let mut property_type = Default::default(); + let mut required = 0; + unsafe { + let _ = SetupDiGetDevicePropertyW( + set, + data, + key, + &mut property_type, + None, + Some(&mut required), + 0, + ); + } + if required < 2 || required > 64 * 1024 { + return None; + } + let mut buffer = vec![0_u8; required as usize]; + unsafe { + SetupDiGetDevicePropertyW( + set, + data, + key, + &mut property_type, + Some(&mut buffer), + Some(&mut required), + 0, + ) + .ok()?; + } + let utf16: Vec = buffer + .chunks_exact(2) + .map(|b| u16::from_le_bytes([b[0], b[1]])) + .collect(); + let end = utf16.iter().position(|&v| v == 0).unwrap_or(utf16.len()); + String::from_utf16(&utf16[..end]) + .ok() + .filter(|s| !s.is_empty()) +} + +fn device_instance_id( + set: windows::Win32::Devices::DeviceAndDriverInstallation::HDEVINFO, + data: &SP_DEVINFO_DATA, +) -> Option { + let mut required = 0; + unsafe { + let _ = SetupDiGetDeviceInstanceIdW(set, data, None, Some(&mut required)); + } + if required < 2 || required > 64 * 1024 { + return None; + } + let mut buffer = vec![0_u16; required as usize]; + unsafe { + SetupDiGetDeviceInstanceIdW(set, data, Some(&mut buffer), Some(&mut required)).ok()?; + } + let end = buffer.iter().position(|&v| v == 0).unwrap_or(buffer.len()); + String::from_utf16(&buffer[..end]) + .ok() + .filter(|s| !s.is_empty()) +} + +fn is_vb_audio_provider(provider: &str) -> bool { + let value = provider.to_ascii_lowercase(); + value.contains("burel vincent") || value.contains("vb-audio") +} + +fn is_vb_cable_friendly_name(name: &str) -> bool { + let value = name.to_ascii_lowercase(); + value.contains("cable") && !value.contains("voicemeeter") +} + +fn is_exact_package_name(name: &str) -> bool { + let value = name.to_ascii_lowercase(); + if !value.starts_with("oem") || !value.ends_with(".inf") { + return false; + } + let digits = &value[3..value.len() - 4]; + !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()) +} + +fn read_manifest() -> ManifestState { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let Ok(key) = hklm.open_subkey_with_flags(REGISTRY_PATH, KEY_READ | KEY_WOW64_64KEY) else { + return ManifestState::Missing; + }; + let Ok(raw) = key.get_value::(REGISTRY_VALUE) else { + return ManifestState::Corrupt; + }; + match serde_json::from_str::(&raw) { + Ok(manifest) if manifest.schema_version == MANIFEST_SCHEMA_VERSION => { + ManifestState::Valid(manifest) + } + _ => ManifestState::Corrupt, + } +} + +fn write_manifest(manifest: &OwnershipManifest) -> Result<(), WindowsVirtualCableError> { + let raw = serde_json::to_string(manifest).map_err(|e| { + WindowsVirtualCableError::new( + "ownershipWriteFailed", + format!("Serialize ownership manifest: {e}"), + ) + })?; + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let (key, _) = hklm + .create_subkey_with_flags(REGISTRY_PATH, KEY_WRITE | KEY_WOW64_64KEY) + .map_err(|e| { + WindowsVirtualCableError::new( + "ownershipWriteFailed", + format!("Open machine ownership store: {e}"), + ) + })?; + key.set_value(REGISTRY_VALUE, &raw).map_err(|e| { + WindowsVirtualCableError::new( + "ownershipWriteFailed", + format!("Write machine ownership store: {e}"), + ) + }) +} + +fn delete_manifest() -> Result<(), WindowsVirtualCableError> { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let key = hklm + .open_subkey_with_flags(REGISTRY_PATH, KEY_WRITE | KEY_WOW64_64KEY) + .map_err(|e| { + WindowsVirtualCableError::new( + "ownershipWriteFailed", + format!("Open machine ownership store: {e}"), + ) + })?; + let _ = key.delete_value(REGISTRY_VALUE); + Ok(()) +} + +struct TemporaryPath(PathBuf); + +impl TemporaryPath { + fn create_dir(prefix: &str) -> Result { + for _ in 0..10 { + let path = + std::env::temp_dir().join(format!("splitwave-{prefix}-{}", cuid2::create_id())); + match fs::create_dir(&path) { + Ok(()) => return Ok(Self(path)), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(WindowsVirtualCableError::new( + "temporaryStorageFailed", + format!("Create temporary directory: {error}"), + )); + } + } + } + Err(WindowsVirtualCableError::new( + "temporaryStorageFailed", + "Could not allocate a unique temporary directory", + )) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TemporaryPath { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +struct TemporaryArchive { + _dir: TemporaryPath, + file: PathBuf, +} + +impl TemporaryArchive { + fn path(&self) -> &Path { + &self.file + } +} + +fn create_temporary_archive( + dir: TemporaryPath, +) -> Result<(TemporaryArchive, PathBuf, File), WindowsVirtualCableError> { + let archive = TemporaryArchive { + file: dir.path().join("VBCABLE_Driver_Pack45.zip"), + _dir: dir, + }; + let part = archive.file.with_extension("zip.part"); + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&part) + .map_err(|e| { + WindowsVirtualCableError::new( + "temporaryStorageFailed", + format!("Create downloaded archive: {e}"), + ) + })?; + Ok((archive, part, file)) +} + +fn finalize_temporary_archive( + archive: TemporaryArchive, + part: &Path, +) -> Result { + fs::rename(part, &archive.file).map_err(|e| { + WindowsVirtualCableError::new( + "temporaryStorageFailed", + format!("Finalize downloaded archive: {e}"), + ) + })?; + Ok(archive) +} + +impl HelperResult { + fn from_operation( + request_id: String, + result: &Result<(), WindowsVirtualCableError>, + installer_exit_code: Option, + ) -> Self { + let detected = detect_cable().unwrap_or_default(); + let manifest = read_manifest(); + let published_inf = detected + .package() + .map(|package| package.published_name.clone()); + let (success, code, message, installer_exit_code) = match result { + Ok(()) => ( + true, + "ok".into(), + "VB-CABLE helper completed".into(), + installer_exit_code, + ), + Err(error) => ( + error.code == "rebootRequired", + error.code.clone(), + error.message.clone(), + error.installer_exit_code.or(installer_exit_code), + ), + }; + Self { + request_id, + success, + code, + message, + installer_exit_code, + render_endpoint_name: detected.render_endpoint_name, + capture_endpoint_name: detected.capture_endpoint_name, + published_inf, + ownership: manifest_ownership(&manifest), + } + } + + fn into_error(self) -> WindowsVirtualCableError { + WindowsVirtualCableError::new(self.code, self.message) + .with_installer_exit_code(self.installer_exit_code) + } +} + +impl HelperResultChannel { + fn create() -> Result { + let directory = TemporaryPath::create_dir("vb-cable-result")?; + let path = directory.path().join("result.json"); + Ok(Self { + _directory: directory, + path, + request_id: cuid2::create_id(), + }) + } + + fn read(&self, helper_exit_code: u32) -> Result { + read_helper_result(&self.path, &self.request_id, helper_exit_code) + } +} + +fn helper_result_target( + args: &[OsString], +) -> Result, WindowsVirtualCableError> { + let path_index = args + .iter() + .position(|value| value.to_str() == Some(HELPER_RESULT_PATH_FLAG)); + let request_index = args + .iter() + .position(|value| value.to_str() == Some(HELPER_REQUEST_ID_FLAG)); + let (Some(path_index), Some(request_index)) = (path_index, request_index) else { + if path_index.is_none() && request_index.is_none() { + return Ok(None); + } + return Err(WindowsVirtualCableError::new( + "invalidHelperArguments", + "Incomplete helper result arguments", + )); + }; + let path = args.get(path_index + 1).map(PathBuf::from).ok_or_else(|| { + WindowsVirtualCableError::new("invalidHelperArguments", "Missing helper result path") + })?; + let request_id = args + .get(request_index + 1) + .and_then(|value| value.to_str()) + .filter(|value| valid_request_id(value)) + .ok_or_else(|| { + WindowsVirtualCableError::new( + "invalidHelperArguments", + "Invalid helper request identifier", + ) + })?; + Ok(Some(HelperResultTarget { + path, + request_id: request_id.into(), + })) +} + +fn valid_request_id(value: &str) -> bool { + (16..=64).contains(&value.len()) + && value + .chars() + .all(|character| character.is_ascii_lowercase() || character.is_ascii_digit()) +} + +fn valid_result_code(value: &str) -> bool { + (1..=64).contains(&value.len()) + && value + .chars() + .all(|character| character.is_ascii_alphanumeric()) +} + +fn write_helper_result(path: &Path, result: &HelperResult) -> Result<(), WindowsVirtualCableError> { + let bytes = serde_json::to_vec(result).map_err(|error| { + WindowsVirtualCableError::new( + "helperResultWriteFailed", + format!("Serialize helper result: {error}"), + ) + })?; + if bytes.len() as u64 > HELPER_RESULT_MAX_BYTES { + return Err(WindowsVirtualCableError::new( + "helperResultWriteFailed", + "Helper result exceeds the allowed size", + )); + } + let part = path.with_extension("json.part"); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&part) + .map_err(|error| { + WindowsVirtualCableError::new( + "helperResultWriteFailed", + format!("Create helper result: {error}"), + ) + })?; + file.write_all(&bytes).map_err(|error| { + WindowsVirtualCableError::new( + "helperResultWriteFailed", + format!("Write helper result: {error}"), + ) + })?; + file.sync_all().map_err(|error| { + WindowsVirtualCableError::new( + "helperResultWriteFailed", + format!("Flush helper result: {error}"), + ) + })?; + drop(file); + fs::rename(&part, path).map_err(|error| { + WindowsVirtualCableError::new( + "helperResultWriteFailed", + format!("Publish helper result: {error}"), + ) + }) +} + +fn read_helper_result( + path: &Path, + request_id: &str, + helper_exit_code: u32, +) -> Result { + let metadata = fs::metadata(path).map_err(|error| { + warn!(helper_exit_code, error = %error, "VB-CABLE helper result is missing"); + WindowsVirtualCableError::new( + "helperResultMissing", + "VB-CABLE helper did not return a diagnostic result", + ) + })?; + if !metadata.is_file() || metadata.len() > HELPER_RESULT_MAX_BYTES { + let _ = fs::remove_file(path); + return Err(WindowsVirtualCableError::new( + "helperResultInvalid", + "VB-CABLE helper returned an invalid diagnostic result", + )); + } + let bytes = fs::read(path).map_err(|error| { + WindowsVirtualCableError::new( + "helperResultInvalid", + format!("Read helper result: {error}"), + ) + }); + let _ = fs::remove_file(path); + let bytes = bytes?; + let result: HelperResult = serde_json::from_slice(&bytes).map_err(|error| { + WindowsVirtualCableError::new( + "helperResultInvalid", + format!("Parse helper result: {error}"), + ) + })?; + if result.request_id != request_id + || !valid_request_id(&result.request_id) + || !valid_result_code(&result.code) + || result.message.is_empty() + || result.message.len() > 2048 + { + return Err(WindowsVirtualCableError::new( + "helperResultInvalid", + "VB-CABLE helper returned an invalid diagnostic result", + )); + } + let expected_exit_code = if result.success { + if result.code == "rebootRequired" { + EXIT_REBOOT_REQUIRED + } else { + 0 + } + } else if result.code == "confirmationRequired" { + EXIT_REQUIRES_CONFIRMATION + } else { + 1 + }; + if helper_exit_code != expected_exit_code { + return Err(WindowsVirtualCableError::new( + "helperResultInvalid", + "VB-CABLE helper result did not match the process exit code", + )); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + const REQUEST_ID: &str = "0123456789abcdef01234567"; + + fn helper_result(success: bool, code: &str) -> HelperResult { + HelperResult { + request_id: REQUEST_ID.into(), + success, + code: code.into(), + message: "helper message".into(), + installer_exit_code: None, + render_endpoint_name: Some("CABLE Input".into()), + capture_endpoint_name: Some("CABLE Output".into()), + published_inf: Some("oem42.inf".into()), + ownership: WindowsVirtualCableOwnership::Managed, + } + } + + fn result_file(prefix: &str) -> Result<(TemporaryPath, PathBuf), WindowsVirtualCableError> { + let directory = TemporaryPath::create_dir(prefix)?; + let path = directory.path().join("result.json"); + Ok((directory, path)) + } + + #[test] + fn cable_device_name_excludes_voicemeeter() { + assert!(is_vb_cable_friendly_name("VB-Audio Virtual Cable")); + assert!(is_vb_cable_friendly_name("CABLE Input")); + assert!(!is_vb_cable_friendly_name("VB-Audio VoiceMeeter VAIO")); + } + + #[test] + fn temporary_archive_is_published_only_after_rename() -> Result<(), WindowsVirtualCableError> { + let (archive, part, mut file) = + create_temporary_archive(TemporaryPath::create_dir("vb-cable-test")?)?; + assert!(!archive.path().exists()); + assert!(part.is_file()); + + file.write_all(b"archive").unwrap(); + file.sync_all().unwrap(); + drop(file); + + let archive = finalize_temporary_archive(archive, &part)?; + assert!(archive.path().is_file()); + assert!(!part.exists()); + Ok(()) + } + + #[test] + fn supplied_consumer_id_requires_exact_hash_format() { + let valid = OsString::from("splitwave-0123456789abcdef01234567"); + let args = [ + OsString::from("splitwave.exe"), + OsString::from(HELPER_FLAG), + OsString::from("remove"), + OsString::from("C:\\Splitwave"), + valid.clone(), + ]; + assert_eq!( + supplied_consumer_id(&args).unwrap(), + valid.to_string_lossy() + ); + + let invalid = OsString::from("splitwave-not-a-valid-consumer"); + let mut args = args; + args[4] = invalid; + assert_eq!( + supplied_consumer_id(&args).unwrap_err().code, + "invalidHelperArguments" + ); + } + + #[test] + fn helper_result_round_trips_and_is_removed() -> Result<(), WindowsVirtualCableError> { + let (_directory, path) = result_file("vb-cable-result-round-trip")?; + let expected = helper_result(true, "ok"); + write_helper_result(&path, &expected)?; + + let actual = read_helper_result(&path, REQUEST_ID, 0)?; + + assert_eq!(actual, expected); + assert!(!path.exists()); + Ok(()) + } + + #[test] + fn helper_result_preserves_unknown_error_code() -> Result<(), WindowsVirtualCableError> { + let (_directory, path) = result_file("vb-cable-result-unknown-code")?; + let expected = helper_result(false, "newVendorFailure"); + write_helper_result(&path, &expected)?; + + let actual = read_helper_result(&path, REQUEST_ID, 1)?; + + assert_eq!(actual.code, "newVendorFailure"); + Ok(()) + } + + #[test] + fn missing_helper_result_is_reported() -> Result<(), WindowsVirtualCableError> { + let (_directory, path) = result_file("vb-cable-result-missing")?; + + let error = read_helper_result(&path, REQUEST_ID, 1).unwrap_err(); + + assert_eq!(error.code, "helperResultMissing"); + Ok(()) + } + + #[test] + fn corrupt_helper_result_is_rejected_and_removed() -> Result<(), WindowsVirtualCableError> { + let (_directory, path) = result_file("vb-cable-result-corrupt")?; + fs::write(&path, b"{").unwrap(); + + let error = read_helper_result(&path, REQUEST_ID, 1).unwrap_err(); + + assert_eq!(error.code, "helperResultInvalid"); + assert!(!path.exists()); + Ok(()) + } + + #[test] + fn oversized_helper_result_is_rejected_and_removed() -> Result<(), WindowsVirtualCableError> { + let (_directory, path) = result_file("vb-cable-result-oversized")?; + fs::write(&path, vec![b'x'; HELPER_RESULT_MAX_BYTES as usize + 1]).unwrap(); + + let error = read_helper_result(&path, REQUEST_ID, 1).unwrap_err(); + + assert_eq!(error.code, "helperResultInvalid"); + assert!(!path.exists()); + Ok(()) + } + + #[test] + fn uac_cancellation_remains_distinct() { + let error = helper_error("install", ERROR_CANCELLED.0); + assert_eq!(error.code, "elevationCancelled"); + } + + #[test] + fn reboot_required_result_matches_reserved_exit_code() -> Result<(), WindowsVirtualCableError> { + let (_directory, path) = result_file("vb-cable-result-reboot")?; + let expected = helper_result(true, "rebootRequired"); + write_helper_result(&path, &expected)?; + + let actual = read_helper_result(&path, REQUEST_ID, EXIT_REBOOT_REQUIRED)?; + + assert!(actual.success); + assert_eq!(actual.code, "rebootRequired"); + Ok(()) + } + + #[test] + fn installer_exit_code_is_preserved_with_specific_failure( + ) -> Result<(), WindowsVirtualCableError> { + let (_directory, path) = result_file("vb-cable-result-installer-exit")?; + let mut expected = helper_result(false, "driverPackageNotDetected"); + expected.installer_exit_code = Some(1); + expected.message = + "VB-CABLE installer finished, but Windows did not register the expected driver".into(); + write_helper_result(&path, &expected)?; + + let error = read_helper_result(&path, REQUEST_ID, 1)?.into_error(); + + assert_eq!(error.code, "driverPackageNotDetected"); + assert_eq!(error.installer_exit_code, Some(1)); + Ok(()) + } +} + +fn download_archive() -> Result { + let parsed = reqwest::Url::parse(VBCABLE_URL).map_err(|_| { + WindowsVirtualCableError::new("unexpectedDownloadHost", "VB-CABLE source URL is invalid") + })?; + if parsed.scheme() != "https" || parsed.host_str() != Some("download.vb-audio.com") { + return Err(WindowsVirtualCableError::new( + "unexpectedDownloadHost", + "VB-CABLE source is not the approved VB-Audio HTTPS host", + )); + } + let client = Client::builder() + .redirect(reqwest::redirect::Policy::custom(|attempt| { + if attempt.url().scheme() == "https" + && attempt.url().host_str() == Some("download.vb-audio.com") + { + attempt.follow() + } else { + attempt.stop() + } + })) + .build() + .map_err(|e| { + WindowsVirtualCableError::new("downloadFailed", format!("Create download client: {e}")) + })?; + let mut response = client + .get(VBCABLE_URL) + .send() + .and_then(|response| response.error_for_status()) + .map_err(|e| { + WindowsVirtualCableError::new("downloadFailed", format!("Download VB-CABLE: {e}")) + })?; + if response + .content_length() + .is_some_and(|size| size > VBCABLE_MAX_ARCHIVE_BYTES) + { + return Err(WindowsVirtualCableError::new( + "archiveInvalid", + "VB-CABLE archive exceeds the allowed download size", + )); + } + let (archive, part, mut out) = + create_temporary_archive(TemporaryPath::create_dir("vb-cable-download")?)?; + let mut hasher = Sha256::new(); + let mut total = 0_u64; + let mut buf = [0_u8; 32 * 1024]; + loop { + let read = response.read(&mut buf).map_err(|e| { + WindowsVirtualCableError::new("downloadFailed", format!("Read VB-CABLE archive: {e}")) + })?; + if read == 0 { + break; + } + total += read as u64; + if total > VBCABLE_MAX_ARCHIVE_BYTES { + return Err(WindowsVirtualCableError::new( + "archiveInvalid", + "VB-CABLE archive exceeds the allowed download size", + )); + } + out.write_all(&buf[..read]).map_err(|e| { + WindowsVirtualCableError::new( + "temporaryStorageFailed", + format!("Write downloaded archive: {e}"), + ) + })?; + hasher.update(&buf[..read]); + } + out.sync_all().map_err(|e| { + WindowsVirtualCableError::new( + "temporaryStorageFailed", + format!("Finalize downloaded archive: {e}"), + ) + })?; + drop(out); + verify_hash(&hasher.finalize())?; + finalize_temporary_archive(archive, &part) +} + +fn copy_verified_archive(source: &Path) -> Result { + let mut input = File::open(source).map_err(|e| { + WindowsVirtualCableError::new("archiveInvalid", format!("Open VB-CABLE archive: {e}")) + })?; + let (archive, part, mut out) = + create_temporary_archive(TemporaryPath::create_dir("vb-cable-helper")?)?; + let mut hasher = Sha256::new(); + let mut total = 0_u64; + let mut buf = [0_u8; 32 * 1024]; + loop { + let read = input.read(&mut buf).map_err(|e| { + WindowsVirtualCableError::new("archiveInvalid", format!("Read VB-CABLE archive: {e}")) + })?; + if read == 0 { + break; + } + total += read as u64; + if total > VBCABLE_MAX_ARCHIVE_BYTES { + return Err(WindowsVirtualCableError::new( + "archiveInvalid", + "VB-CABLE archive exceeds the allowed download size", + )); + } + out.write_all(&buf[..read]).map_err(|e| { + WindowsVirtualCableError::new( + "temporaryStorageFailed", + format!("Write helper archive: {e}"), + ) + })?; + hasher.update(&buf[..read]); + } + out.sync_all().map_err(|e| { + WindowsVirtualCableError::new( + "temporaryStorageFailed", + format!("Finalize helper archive: {e}"), + ) + })?; + drop(out); + verify_hash(&hasher.finalize())?; + finalize_temporary_archive(archive, &part) +} + +fn verify_hash(digest: &[u8]) -> Result<(), WindowsVirtualCableError> { + let actual = digest + .iter() + .map(|byte| format!("{byte:02X}")) + .collect::(); + if actual == VBCABLE_ARCHIVE_SHA256 { + Ok(()) + } else { + Err(WindowsVirtualCableError::new( + "checksumMismatch", + "The downloaded VB-CABLE archive does not match Splitwave's approved SHA-256", + )) + } +} + +fn extract_verified_archive(archive: &Path) -> Result { + let file = File::open(archive).map_err(|e| { + WindowsVirtualCableError::new("archiveInvalid", format!("Open verified archive: {e}")) + })?; + let mut zip = ZipArchive::new(file).map_err(|e| { + WindowsVirtualCableError::new("archiveInvalid", format!("Read VB-CABLE archive: {e}")) + })?; + if zip.len() > VBCABLE_MAX_ARCHIVE_FILES { + return Err(WindowsVirtualCableError::new( + "archiveInvalid", + "VB-CABLE archive contains too many files", + )); + } + let dir = TemporaryPath::create_dir("vb-cable-extract")?; + let mut total = 0_u64; + for index in 0..zip.len() { + let mut entry = zip.by_index(index).map_err(|e| { + WindowsVirtualCableError::new( + "archiveInvalid", + format!("Read VB-CABLE archive entry: {e}"), + ) + })?; + let Some(name) = entry.enclosed_name().map(PathBuf::from) else { + return Err(WindowsVirtualCableError::new( + "archiveInvalid", + "VB-CABLE archive contains an unsafe path", + )); + }; + if entry.is_dir() { + continue; + } + total += entry.size(); + if total > VBCABLE_MAX_EXTRACTED_BYTES { + return Err(WindowsVirtualCableError::new( + "archiveInvalid", + "VB-CABLE archive expands beyond the allowed size", + )); + } + let output = dir.path().join(name.file_name().ok_or_else(|| { + WindowsVirtualCableError::new( + "archiveInvalid", + "VB-CABLE archive entry has no file name", + ) + })?); + let mut output_file = OpenOptions::new() + .write(true) + .create_new(true) + .open(output) + .map_err(|e| { + WindowsVirtualCableError::new( + "temporaryStorageFailed", + format!("Extract VB-CABLE archive: {e}"), + ) + })?; + std::io::copy(&mut entry, &mut output_file).map_err(|e| { + WindowsVirtualCableError::new( + "archiveInvalid", + format!("Extract VB-CABLE archive entry: {e}"), + ) + })?; + } + if !dir.path().join(setup_program_name()).is_file() { + return Err(WindowsVirtualCableError::new( + "installerNotFound", + "VB-CABLE archive does not contain the setup program for this architecture", + )); + } + Ok(dir) +} + +fn setup_program_name() -> &'static str { + if cfg!(target_arch = "x86") { + "VBCABLE_Setup.exe" + } else { + // VB-Audio's current x64 setup supports both x64 Windows and Windows on Arm64. + "VBCABLE_Setup_x64.exe" + } +} + +fn verify_authenticode(installer: &Path) -> Result<(), WindowsVirtualCableError> { + const SCRIPT: &str = r#"param([Parameter(Mandatory = $true)][string]$InstallerPath) +$signature = Get-AuthenticodeSignature -LiteralPath $InstallerPath +if ($signature.Status -ne 'Valid') { exit 2 } +if ($signature.SignerCertificate.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) -ne 'BUREL VINCENT Entrepreneur individuel') { exit 3 } +if ($signature.SignerCertificate.Thumbprint -ne 'A77952D93229D0EC36E2543081EEA7D125732B9C') { exit 4 } +exit 0 +"#; + let script_dir = TemporaryPath::create_dir("vb-cable-signature")?; + let script = script_dir.path().join("verify-authenticode.ps1"); + fs::write(&script, SCRIPT).map_err(|e| { + WindowsVirtualCableError::new( + "temporaryStorageFailed", + format!("Create Authenticode verification script: {e}"), + ) + })?; + let status = Command::new("powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-File"]) + .arg(&script) + .arg(installer) + .status() + .map_err(|e| { + WindowsVirtualCableError::new( + "invalidSignature", + format!("Start Authenticode verification: {e}"), + ) + })?; + match status.code() { + Some(0) => Ok(()), + Some(3 | 4) => Err(WindowsVirtualCableError::new( + "unexpectedPublisher", + "VB-CABLE setup was signed by an unexpected publisher", + )), + _ => Err(WindowsVirtualCableError::new( + "invalidSignature", + "VB-CABLE setup does not have a valid Authenticode signature", + )), + } +} + +fn current_install_root() -> Result { + std::env::current_exe() + .map_err(|e| { + WindowsVirtualCableError::new( + "operationFailed", + format!("Locate Splitwave executable: {e}"), + ) + })? + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| { + WindowsVirtualCableError::new( + "operationFailed", + "Splitwave executable has no installation directory", + ) + }) +} + +fn consumer_id(root: &Path) -> Result { + let normalized = root + .canonicalize() + .unwrap_or_else(|_| root.to_path_buf()) + .to_string_lossy() + .replace('/', "\\") + .to_ascii_lowercase(); + let identity = format!("{}|{normalized}", current_user_sid()?); + let hash = Sha256::digest(identity.as_bytes()); + Ok(format!( + "splitwave-{}", + hash.iter() + .take(12) + .map(|b| format!("{b:02x}")) + .collect::() + )) +} + +fn supplied_consumer_id(args: &[OsString]) -> Result { + let consumer = args + .get(4) + .and_then(|value| value.to_str()) + .ok_or_else(|| { + WindowsVirtualCableError::new( + "invalidHelperArguments", + "Missing VB-CABLE consumer identity", + ) + })?; + let valid = consumer.strip_prefix("splitwave-").is_some_and(|hash| { + hash.len() == 24 && hash.chars().all(|character| character.is_ascii_hexdigit()) + }); + if !valid { + return Err(WindowsVirtualCableError::new( + "invalidHelperArguments", + "Invalid VB-CABLE consumer identity", + )); + } + Ok(consumer.into()) +} + +fn helper_consumer_id(args: &[OsString], root: &Path) -> Result { + if args.get(4).is_some() { + supplied_consumer_id(args) + } else { + consumer_id(root) + } +} + +fn args_with_consumer(args: &[OsString], consumer: &str) -> Vec { + if args.get(4).is_some() { + return args.to_vec(); + } + let mut rerun_args = args.to_vec(); + rerun_args.push(OsString::from(consumer)); + rerun_args +} + +fn current_user_sid() -> Result { + let mut token = HANDLE::default(); + unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }.map_err(|e| { + WindowsVirtualCableError::new("consumerIdentityFailed", format!("Open process token: {e}")) + })?; + let result = (|| { + let mut required = 0; + unsafe { + let _ = GetTokenInformation(token, TokenUser, None, 0, &mut required); + } + if required == 0 { + return Err(WindowsVirtualCableError::new( + "consumerIdentityFailed", + "Windows did not report the size of the current user token", + )); + } + let words = (required as usize).div_ceil(std::mem::size_of::()); + let mut buffer = vec![0_usize; words]; + let user = buffer.as_mut_ptr().cast::(); + unsafe { + GetTokenInformation( + token, + TokenUser, + Some(user.cast::()), + required, + &mut required, + ) + } + .map_err(|e| { + WindowsVirtualCableError::new( + "consumerIdentityFailed", + format!("Read process user token: {e}"), + ) + })?; + let mut sid = PWSTR::null(); + unsafe { ConvertSidToStringSidW((*user).User.Sid, &mut sid) }.map_err(|e| { + WindowsVirtualCableError::new( + "consumerIdentityFailed", + format!("Format process user SID: {e}"), + ) + })?; + let value = unsafe { sid.to_string() }.map_err(|e| { + WindowsVirtualCableError::new( + "consumerIdentityFailed", + format!("Read process user SID: {e}"), + ) + }); + unsafe { + let _ = LocalFree(Some(HLOCAL(sid.0.cast()))); + } + value + })(); + unsafe { + let _ = CloseHandle(token); + } + result +} + +fn now_unix_seconds() -> String { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .to_string() +} + +fn helper_error(action: &str, exit_code: u32) -> WindowsVirtualCableError { + if exit_code == ERROR_CANCELLED.0 as u32 { + WindowsVirtualCableError::new("elevationCancelled", "Administrator approval was cancelled") + } else { + warn!(action, exit_code, "VB-CABLE elevated helper failed"); + WindowsVirtualCableError::new( + "installerFailed", + match action { + "install" => { + "VB-CABLE could not be installed. No usable virtual microphone was detected." + } + "remove" => "VB-CABLE could not be removed. The existing driver was preserved.", + _ => "The VB-CABLE operation could not be completed.", + }, + ) + } +} + +fn elevate_current(args: &[OsString]) -> Result { + let executable = std::env::current_exe().map_err(|e| { + WindowsVirtualCableError::new("operationFailed", format!("Locate Splitwave helper: {e}")) + })?; + let file = wide(executable.as_os_str()); + let verb = wide("runas"); + let parameters = wide( + &args + .iter() + .map(|arg| quote_windows_arg(arg)) + .collect::>() + .join(" "), + ); + let mut execute = SHELLEXECUTEINFOW { + cbSize: std::mem::size_of::() as u32, + fMask: SEE_MASK_NOCLOSEPROCESS, + lpVerb: PCWSTR(verb.as_ptr()), + lpFile: PCWSTR(file.as_ptr()), + lpParameters: PCWSTR(parameters.as_ptr()), + nShow: 1, + ..Default::default() + }; + unsafe { ShellExecuteExW(&mut execute) }.map_err(|e| { + let cancelled = (e.code().0 as u32 & 0xffff) == ERROR_CANCELLED.0; + WindowsVirtualCableError::new( + if cancelled { + "elevationCancelled" + } else { + "elevationFailed" + }, + "Administrator approval is required to manage VB-CABLE", + ) + })?; + unsafe { + WaitForSingleObject(execute.hProcess, INFINITE); + } + let mut code = 1; + unsafe { GetExitCodeProcess(execute.hProcess, &mut code) }.map_err(|e| { + WindowsVirtualCableError::new("operationFailed", format!("Read helper exit code: {e}")) + })?; + unsafe { + let _ = CloseHandle(execute.hProcess); + } + Ok(code) +} + +fn quote_windows_arg(value: &OsString) -> String { + let value = value.to_string_lossy(); + let mut result = String::from("\""); + let mut slashes = 0; + for character in value.chars() { + match character { + '\\' => slashes += 1, + '\"' => { + result.push_str(&"\\".repeat(slashes * 2 + 1)); + result.push('\"'); + slashes = 0; + } + _ => { + result.push_str(&"\\".repeat(slashes)); + result.push(character); + slashes = 0; + } + } + } + result.push_str(&"\\".repeat(slashes * 2)); + result.push('\"'); + result +} + +fn wide(value: impl AsRef) -> Vec { + value + .as_ref() + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} + +fn is_elevated() -> Result { + let mut token = HANDLE::default(); + unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }.map_err(|e| { + WindowsVirtualCableError::new("elevationFailed", format!("Open process token: {e}")) + })?; + let mut elevation = TOKEN_ELEVATION::default(); + let mut returned = 0; + let result = unsafe { + GetTokenInformation( + token, + TokenElevation, + Some((&mut elevation as *mut TOKEN_ELEVATION).cast::()), + std::mem::size_of::() as u32, + &mut returned, + ) + }; + unsafe { + let _ = CloseHandle(token); + } + result.map(|_| elevation.TokenIsElevated != 0).map_err(|e| { + WindowsVirtualCableError::new("elevationFailed", format!("Read process token: {e}")) + }) +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 38b7a7f..f96e448 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -385,6 +385,30 @@ pub fn virtual_driver_status() -> VirtualDriverStatus { virtual_device::status() } +#[tauri::command] +pub async fn windows_virtual_cable_status( +) -> Result { + tauri::async_runtime::spawn_blocking(virtual_device::windows_virtual_cable_status) + .await + .map_err(|_| { + virtual_device::WindowsVirtualCableError::operation_failed( + "Status query stopped unexpectedly", + ) + })? +} + +#[tauri::command] +pub async fn install_windows_virtual_cable( +) -> Result { + tauri::async_runtime::spawn_blocking(virtual_device::install_windows_virtual_cable) + .await + .map_err(|_| { + virtual_device::WindowsVirtualCableError::operation_failed( + "Installation task stopped unexpectedly", + ) + })? +} + #[tauri::command] pub fn install_virtual_driver(app: AppHandle) -> Result<(), String> { virtual_device::install(&app) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c2deebe..c270e5d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,6 +25,11 @@ static APP_HANDLE: OnceLock = OnceLock::new(); // line) and replayed on the next launch. static CRASH_FILE: OnceLock = OnceLock::new(); +#[cfg(target_os = "windows")] +pub fn run_windows_vb_cable_helper() -> Option { + audio::virtual_device::windows_cable::run_helper() +} + pub fn app_handle() -> Option<&'static AppHandle> { APP_HANDLE.get() } @@ -230,6 +235,8 @@ pub fn run() { commands::list_audio_applications, commands::get_app_icons, commands::virtual_driver_status, + commands::windows_virtual_cable_status, + commands::install_windows_virtual_cable, commands::install_virtual_driver, commands::uninstall_virtual_driver, commands::apply_virtual_devices, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9a5f982..a796d7d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -8,5 +8,9 @@ embed_plist::embed_info_plist!("../Info.plist"); fn main() { + #[cfg(target_os = "windows")] + if let Some(exit_code) = splitwave_lib::run_windows_vb_cable_helper() { + std::process::exit(exit_code); + } splitwave_lib::run() } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4b2b140..a618245 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -18,7 +18,12 @@ "active": true, "targets": "all", "createUpdaterArtifacts": true, - "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"] + "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], + "windows": { + "nsis": { + "installerHooks": "./windows/hooks.nsh" + } + } }, "plugins": { "updater": { diff --git a/src-tauri/windows/hooks.nsh b/src-tauri/windows/hooks.nsh new file mode 100644 index 0000000..8af49f7 --- /dev/null +++ b/src-tauri/windows/hooks.nsh @@ -0,0 +1,33 @@ +!include "LogicLib.nsh" +!include "FileFunc.nsh" + +!macro NSIS_HOOK_POSTINSTALL + ; The helper only registers a consumer for an already matching managed package. + ; A clean Splitwave installation therefore does not install or elevate for VB-CABLE. + ExecWait '"$INSTDIR\Splitwave.exe" --vb-cable-helper register-consumer "$INSTDIR"' $0 +!macroend + +!macro NSIS_HOOK_PREUNINSTALL + ; Tauri invokes the old uninstaller during updates with /UPDATE. + ${GetOptions} "$CMDLINE" "/UPDATE" $0 + ${IfNot} ${Errors} + Goto vb_cable_done + ${EndIf} + + ; There is no safe confirmation path in silent mode. + IfSilent vb_cable_done + + ExecWait '"$INSTDIR\Splitwave.exe" --vb-cable-helper unregister "$INSTDIR"' $0 + ${If} $0 != 20 + Goto vb_cable_done + ${EndIf} + + MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "VB-CABLE was installed by Splitwave and may also be used by other applications.$\r$\n$\r$\nRemove VB-CABLE as well?" IDYES vb_cable_remove + ExecWait '"$INSTDIR\Splitwave.exe" --vb-cable-helper retain "$INSTDIR"' $0 + Goto vb_cable_done + + vb_cable_remove: + ExecWait '"$INSTDIR\Splitwave.exe" --vb-cable-helper remove "$INSTDIR"' $0 + + vb_cable_done: +!macroend diff --git a/src/lib/components/layout/header_nav.svelte b/src/lib/components/layout/header_nav.svelte new file mode 100644 index 0000000..9870335 --- /dev/null +++ b/src/lib/components/layout/header_nav.svelte @@ -0,0 +1,10 @@ + + + diff --git a/src/lib/modules/audio/methods.ts b/src/lib/modules/audio/methods.ts index 7f3ba76..e693010 100644 --- a/src/lib/modules/audio/methods.ts +++ b/src/lib/modules/audio/methods.ts @@ -13,6 +13,7 @@ import type { StartPipelinePayload, VirtualDeviceConfig, VirtualDriverStatus, + WindowsVirtualCableStatus, VolumeChange } from './types'; @@ -64,6 +65,8 @@ export const methods = { onState: (cb: (e: AudioStateEvent) => void): Promise => listen(AUDIO_STATE_EVENT, (evt) => cb(evt.payload)), onSpeakerError: (cb: () => void): Promise => listen('audio://speaker_error', () => cb()), virtualDriverStatus: (): Promise => invoke('virtual_driver_status'), + windowsVirtualCableStatus: (): Promise => invoke('windows_virtual_cable_status'), + installWindowsVirtualCable: (): Promise => invoke('install_windows_virtual_cable'), installVirtualDriver: (): Promise => invoke('install_virtual_driver'), uninstallVirtualDriver: (): Promise => invoke('uninstall_virtual_driver'), applyVirtualDevices: (devices: VirtualDeviceConfig[]): Promise => invoke('apply_virtual_devices', { devices }), diff --git a/src/lib/modules/audio/types.ts b/src/lib/modules/audio/types.ts index d0d731a..90a3adc 100644 --- a/src/lib/modules/audio/types.ts +++ b/src/lib/modules/audio/types.ts @@ -82,6 +82,36 @@ export interface VirtualDeviceConfig { channels: number; } +export type WindowsVirtualCableState = + | 'notInstalled' + | 'installedExternal' + | 'installedManaged' + | 'partial' + | 'rebootRequired' + | 'removalPendingReboot' + | 'unknownOwnership'; + +export type WindowsVirtualCableOwnership = 'external' | 'managed' | 'unknown'; + +export interface WindowsVirtualCableStatus { + state: WindowsVirtualCableState; + usable: boolean; + provider: string; + installedVersion: string | null; + renderEndpointName: string | null; + captureEndpointName: string | null; + ownership: WindowsVirtualCableOwnership; + managedBySplitwave: boolean; + rebootRequired: boolean; + detail: string | null; +} + +export interface WindowsVirtualCableError { + code: string; + message: string; + installerExitCode?: number | null; +} + export type AudioStateEvent = { kind: 'started' } | { kind: 'stopped' } | { kind: 'error'; message: string }; export interface StartPipelinePayload { diff --git a/src/lib/modules/flow/ui/input/microphone.svelte b/src/lib/modules/flow/ui/input/microphone.svelte index 1d30835..a1048cc 100644 --- a/src/lib/modules/flow/ui/input/microphone.svelte +++ b/src/lib/modules/flow/ui/input/microphone.svelte @@ -15,7 +15,7 @@ import { onDestroy, onMount } from 'svelte'; import { platform } from '@tauri-apps/plugin-os'; - const supportsVirtualDevices = platform() !== 'windows'; + const isWindows = platform() === 'windows'; type MicrophoneNodeType = Node; let { id, data }: NodeProps = $props(); @@ -87,7 +87,7 @@ refresh()}> {#snippet footer(close)} - {#if supportsVirtualDevices} + {#if !isWindows} ; let { id, data }: NodeProps = $props(); @@ -35,6 +36,10 @@ await audioStore.refreshOutputDevices(); } + async function openVirtualDevice() { + await goto('/virtual-devices'); + } + let unlistenRefresh: (() => void) | undefined; onMount(() => { unlistenRefresh = onNodeAction(id, 'refresh', () => refresh()); @@ -89,15 +94,13 @@ refresh()}> {#snippet footer(close)} - {#if supportsVirtualDevices} - { - close(); - goto('/virtual-devices'); - }} /> - {/if} + { + close(); + void openVirtualDevice(); + }} /> {/snippet} {#if missing} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a15837f..6ea591c 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -37,7 +37,7 @@ } import Header from '$lib/components/layout/header.svelte'; - import { page } from '$app/state'; + import HeaderNav from '$lib/components/layout/header_nav.svelte'; import { audioStore } from '$lib/modules/audio/stores.svelte'; import { methods as audioMethods } from '$lib/modules/audio/methods'; import { RunningTimer, DriverUpdateBanner } from '$lib/modules/audio/ui'; @@ -71,15 +71,7 @@
{#snippet left()} -
- Pipelines - {#if !isWindows} - Virtual devices - {/if} - - Wiki - Settings -
+ {/snippet}
diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 317d405..a37bfc6 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -1,9 +1,8 @@ + +
+
+
+ +
+
+

VB-CABLE virtual microphone

+ {#if checking} +

Checking Windows audio...

+ {:else if status?.state === 'installedExternal'} +

Installed outside Splitwave

+ {:else if status?.state === 'unknownOwnership'} +

Installed, ownership could not be verified

+ {:else if ready} +

Ready

+ {:else if needsRestart} +

Restart Windows to finish setup

+ {:else if incomplete} +

Installation incomplete

+ {:else if status?.state === 'notInstalled'} +

Not installed

+ {:else} +

Status unavailable

+ {/if} +
+ {#if !checking && status?.state === 'notInstalled'} + + {:else if !installing && (issue || needsRestart || incomplete || status?.state === 'unknownOwnership')} + + {/if} +
+ + {#if ready} +
+
+ Speaker output + {status?.renderEndpointName} +
+
+ Microphone input + {status?.captureEndpointName} +
+
+ {/if} + + {#if needsRestart} +

+ VB-CABLE is present, but Windows has not exposed both audio endpoints yet. Splitwave checks again when this window regains focus. +

+ {:else if status?.state === 'installedExternal'} +

Splitwave will use this installation without claiming or removing it.

+ {:else if status?.state === 'unknownOwnership'} +

The existing driver will be preserved and not changed.

+ {:else if incomplete} +

Splitwave found only part of the expected VB-CABLE installation and will not modify it.

+ {:else if status?.state === 'notInstalled'} +

+ Splitwave downloads the standard VB-Audio package from its official source. Administrator approval and a Windows restart may be required. +

+ {/if} + + {#if status?.detail} +

{status.detail}

+ {/if} + + {#if issue} +

{issue}

+ {/if} + +
+ VB-CABLE is third-party donationware by VB-Audio. + +
+
diff --git a/src/routes/wiki/+page.svelte b/src/routes/wiki/+page.svelte index 0eecd80..f7ed133 100644 --- a/src/routes/wiki/+page.svelte +++ b/src/routes/wiki/+page.svelte @@ -1,9 +1,6 @@