From 8914b40623a080a9a7e45f2b54e1654d0c79ebc0 Mon Sep 17 00:00:00 2001 From: DeadMorose777 Date: Mon, 17 Aug 2026 17:11:36 +0300 Subject: [PATCH 01/30] feat(windows): add managed VB-CABLE lifecycle on-behalf-of: @RedRatInHat --- src-tauri/Cargo.lock | 172 ++- src-tauri/Cargo.toml | 7 + src-tauri/src/audio/virtual_device/mod.rs | 6 + .../src/audio/virtual_device/windows_cable.rs | 617 +++++++++ .../virtual_device/windows_cable/platform.rs | 1141 +++++++++++++++++ src-tauri/src/commands.rs | 39 + src-tauri/src/lib.rs | 3 + src-tauri/src/main.rs | 4 + src-tauri/tauri.conf.json | 7 +- src-tauri/windows/hooks.nsh | 36 + 10 files changed, 2028 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/audio/virtual_device/windows_cable.rs create mode 100644 src-tauri/src/audio/virtual_device/windows_cable/platform.rs create mode 100644 src-tauri/windows/hooks.nsh 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..3e2e822 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -115,6 +115,10 @@ 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", @@ -122,11 +126,14 @@ windows = { version = "0.61", features = [ "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/src/audio/virtual_device/mod.rs b/src-tauri/src/audio/virtual_device/mod.rs index bacc684..474c8e3 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, uninstall_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..e3e49dd --- /dev/null +++ b/src-tauri/src/audio/virtual_device/windows_cable.rs @@ -0,0 +1,617 @@ +//! 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: &'static str, + pub message: String, +} + +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: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } +} + +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, 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]) + } +} + +#[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, + } +} + +#[cfg(target_os = "windows")] +mod platform; + +#[cfg(target_os = "windows")] +pub use platform::{install, run_helper, status, uninstall}; + +#[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 uninstall() -> 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() +} + +pub fn uninstall_windows_virtual_cable( +) -> Result { + uninstall() +} + +#[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 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 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 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..23d4241 --- /dev/null +++ b/src-tauri/src/audio/virtual_device/windows_cable/platform.rs @@ -0,0 +1,1141 @@ +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 sha2::{Digest, Sha256}; +use tracing::{info, warn}; +use windows::core::PCWSTR; +use windows::Win32::Devices::DeviceAndDriverInstallation::{ + SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInfo, SetupDiGetClassDevsW, + SetupDiGetDeviceInstanceIdW, SetupDiGetDevicePropertyW, DIGCF_ALLCLASSES, SP_DEVINFO_DATA, +}; +use windows::Win32::Devices::Properties::{ + DEVPKEY_Device_DriverInfPath, DEVPKEY_Device_DriverProvider, DEVPKEY_Device_DriverVersion, + DEVPKEY_Device_FriendlyName, +}; +use windows::Win32::Foundation::{CloseHandle, ERROR_CANCELLED, ERROR_NO_MORE_ITEMS, HANDLE}; +use windows::Win32::Security::{GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY}; +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::{ + determine_state, removal_decision, status_from, 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 EXIT_REQUIRES_CONFIRMATION: i32 = 20; +const EXIT_REBOOT_REQUIRED: i32 = 21; + +static OPERATION_IN_PROGRESS: AtomicBool = AtomicBool::new(false); + +struct OperationGuard; + +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 archive = download_archive()?; + let exit_code = elevate_current(&[ + OsString::from(HELPER_FLAG), + OsString::from("install"), + archive.path().as_os_str().to_owned(), + ])?; + match exit_code { + 0 | EXIT_REBOOT_REQUIRED => status(), + code => Err(helper_error("install", code)), + } +} + +pub fn uninstall() -> Result { + let _guard = OperationGuard::acquire()?; + let root = current_install_root()?; + let exit_code = elevate_current(&[ + OsString::from(HELPER_FLAG), + OsString::from("remove"), + root.into_os_string(), + ])?; + match exit_code { + 0 | EXIT_REBOOT_REQUIRED => status(), + code => Err(helper_error("remove", code)), + } +} + +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 = + match action { + Some("install") => args + .get(3) + .map(PathBuf::from) + .ok_or_else(|| { + WindowsVirtualCableError::new( + "invalidHelperArguments", + "Missing VB-CABLE archive path", + ) + }) + .and_then(|archive| ensure_elevated_or_rerun(&args, || helper_install(&archive))), + 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| ensure_elevated_or_rerun(&args, || retain_as_external(&root))), + Some("remove") => root() + .and_then(|root| ensure_elevated_or_rerun(&args, || remove_exact_package(&root))), + _ => Err(WindowsVirtualCableError::new( + "invalidHelperArguments", + "Unknown VB-CABLE helper action", + )), + }; + Some(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 + } + }) +} + +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 = consumer_id(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, || release_consumer(root)), + RemovalAction::RetainAsExternal => Err(WindowsVirtualCableError::confirmation_required()), + RemovalAction::RemoveExactPackage => Err(WindowsVirtualCableError::new( + "ownershipConflict", + "Unexpected VB-CABLE uninstaller state", + )), + } +} + +fn helper_install(archive: &Path) -> Result<(), WindowsVirtualCableError> { + let before = detect_cable()?; + 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}"), + ) + })?; + if !result.success() { + return Err(WindowsVirtualCableError::new( + "installerFailed", + format!("VB-CABLE setup exited with {}", result.code().unwrap_or(-1)), + )); + } + + let after = detect_cable()?; + let package = after + .packages + .iter() + .find(|candidate| !before.packages.iter().any(|old| old.published_name.eq_ignore_ascii_case(&candidate.published_name))) + .or_else(|| after.package()) + .ok_or_else(|| WindowsVirtualCableError::new("driverPackageNotFound", "VB-CABLE setup completed, but Splitwave could not identify its exact driver package"))? + .clone(); + 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", + )); + } + + let root = current_install_root()?; + 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_id(&root)], + // VB-Audio's current reference manual requires a restart after installation. + pending_reboot: !after.usable(), + removal_pending_reboot: false, + }; + write_manifest(&manifest)?; + if !after.usable() { + return Err(WindowsVirtualCableError::new("rebootRequired", "VB-CABLE was installed, but Windows must be restarted before both endpoints are available")); + } + Ok(()) +} + +fn register_consumer(root: &Path) -> 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(()); + } + let consumer = consumer_id(root); + if !manifest + .consumer_installation_ids + .iter() + .any(|id| id == &consumer) + { + manifest.consumer_installation_ids.push(consumer); + 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 = consumer_id(root); + if !manifest.matches(package) + || manifest + .consumer_installation_ids + .iter() + .any(|id| id == &consumer) + { + return Ok(()); + } + ensure_elevated_or_rerun(args, || register_consumer(root)) +} + +fn release_consumer(root: &Path) -> Result<(), WindowsVirtualCableError> { + let ManifestState::Valid(mut manifest) = read_manifest() else { + return Ok(()); + }; + let consumer = consumer_id(root); + manifest + .consumer_installation_ids + .retain(|id| id != &consumer); + write_manifest(&manifest) +} + +fn retain_as_external(root: &Path) -> Result<(), WindowsVirtualCableError> { + let detected = detect_cable()?; + let ManifestState::Valid(mut manifest) = read_manifest() else { + return Ok(()); + }; + let consumer = consumer_id(root); + 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 remove_exact_package(root: &Path) -> 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 consumer = consumer_id(root); + 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 outputs = crate::audio::device::list_outputs() + .map_err(|e| WindowsVirtualCableError::new("deviceEnumerationFailed", e.to_string()))?; + let inputs = crate::audio::device::list_inputs() + .map_err(|e| WindowsVirtualCableError::new("deviceEnumerationFailed", e.to_string()))?; + let render_endpoint_name = outputs + .into_iter() + .find_map(|d| is_render_name(&d.name).then_some(d.name)); + let capture_endpoint_name = inputs + .into_iter() + .find_map(|d| is_capture_name(&d.name).then_some(d.name)); + Ok(DetectedCable { + render_endpoint_name, + capture_endpoint_name, + packages: enumerate_vb_cable_packages()?, + }) +} + +fn is_render_name(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + name.contains("cable") && name.contains("input") && !name.contains("voicemeeter") +} + +fn is_capture_name(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + name.contains("cable") && name.contains("output") && !name.contains("voicemeeter") +} + +fn enumerate_vb_cable_packages() -> Result, WindowsVirtualCableError> { + let set = unsafe { SetupDiGetClassDevsW(None, None::, None, DIGCF_ALLCLASSES) } + .map_err(|e| { + WindowsVirtualCableError::new( + "deviceEnumerationFailed", + format!("SetupAPI device list: {e}"), + ) + })?; + struct DeviceSet(windows::Win32::Devices::DeviceAndDriverInstallation::HDEVINFO); + impl Drop for DeviceSet { + fn drop(&mut self) { + unsafe { + let _ = SetupDiDestroyDeviceInfoList(self.0); + } + } + } + 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); + let friendly_name = property_string(set, &data, &DEVPKEY_Device_FriendlyName); + let (Some(provider), Some(published_name), Some(friendly_name)) = + (provider, published_name, friendly_name) + else { + continue; + }; + if !is_vb_audio_provider(&provider) + || !is_vb_cable_friendly_name(&friendly_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) +} + +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 { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let path = + std::env::temp_dir().join(format!("splitwave-{prefix}-{}-{nonce}", std::process::id())); + fs::create_dir(&path).map_err(|e| { + WindowsVirtualCableError::new( + "temporaryStorageFailed", + format!("Create temporary directory: {e}"), + ) + })?; + Ok(Self(path)) + } + + 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 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 dir = TemporaryPath::create_dir("vb-cable-download")?; + let file = dir.path().join("VBCABLE_Driver_Pack45.zip"); + let mut out = OpenOptions::new() + .write(true) + .create_new(true) + .open(&file) + .map_err(|e| { + WindowsVirtualCableError::new( + "temporaryStorageFailed", + format!("Create downloaded archive: {e}"), + ) + })?; + 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}"), + ) + })?; + verify_hash(&hasher.finalize())?; + Ok(TemporaryArchive { _dir: dir, file }) +} + +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 dir = TemporaryPath::create_dir("vb-cable-helper")?; + let file = dir.path().join("VBCABLE_Driver_Pack45.zip"); + let mut out = OpenOptions::new() + .write(true) + .create_new(true) + .open(&file) + .map_err(|e| { + WindowsVirtualCableError::new( + "temporaryStorageFailed", + format!("Create helper archive: {e}"), + ) + })?; + 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}"), + ) + })?; + verify_hash(&hasher.finalize())?; + Ok(TemporaryArchive { _dir: dir, file }) +} + +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) -> String { + let normalized = root + .canonicalize() + .unwrap_or_else(|_| root.to_path_buf()) + .to_string_lossy() + .replace('/', "\\") + .to_ascii_lowercase(); + let hash = Sha256::digest(normalized.as_bytes()); + format!( + "splitwave-{}", + hash.iter() + .take(12) + .map(|b| format!("{b:02x}")) + .collect::() + ) +} + +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 { + WindowsVirtualCableError::new( + "installerFailed", + format!("VB-CABLE {action} helper exited with {exit_code}"), + ) + } +} + +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..590a6ff 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -385,6 +385,45 @@ 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 async fn uninstall_windows_virtual_cable( + confirmed: bool, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + if !confirmed { + return Err(virtual_device::WindowsVirtualCableError::confirmation_required()); + } + + let tx = state.audio_tx.clone(); + let stopped = audio_request(tx, |reply| Command::Stop { reply }) + .await + .map_err(|e| virtual_device::WindowsVirtualCableError::operation_failed(e.to_string()))?; + if stopped.is_ok() { + let _ = app.emit(STATE_EVENT, json!({ "kind": "stopped" })); + } + + tauri::async_runtime::spawn_blocking(virtual_device::uninstall_windows_virtual_cable) + .await + .map_err(|_| virtual_device::WindowsVirtualCableError::operation_failed("Uninstallation 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..2a188d7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -230,6 +230,9 @@ 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::uninstall_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..7463d64 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::audio::virtual_device::windows_cable::run_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..5a6bc8c --- /dev/null +++ b/src-tauri/windows/hooks.nsh @@ -0,0 +1,36 @@ +!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. Neither an + ; upgrade nor a repair is an application removal, so leave VB-CABLE untouched. + ${GetOptions} "$CMDLINE" "/UPDATE" $0 + ${IfNot} ${Errors} + Goto vb_cable_done + ${EndIf} + ${GetOptions} "$CMDLINE" "/REPAIR" $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 + 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 + ${EndIf} + + vb_cable_remove: + ExecWait '"$INSTDIR\Splitwave.exe" --vb-cable-helper remove "$INSTDIR"' $0 + + vb_cable_done: +!macroend From f7ce741545fbde029ad8248feed76ee212427e0b Mon Sep 17 00:00:00 2001 From: DeadMorose777 Date: Mon, 17 Aug 2026 17:11:42 +0300 Subject: [PATCH 02/30] feat(windows): expose virtual microphone setup on-behalf-of: @RedRatInHat --- src/lib/modules/audio/methods.ts | 4 + src/lib/modules/audio/types.ts | 29 ++++ .../modules/flow/ui/input/microphone.svelte | 18 +- src/lib/modules/flow/ui/output/speaker.svelte | 18 +- src/routes/+page.svelte | 4 +- src/routes/settings/+page.svelte | 6 +- src/routes/virtual-devices/+page.svelte | 160 ++++++++++++++++-- src/routes/wiki/+page.svelte | 6 +- 8 files changed, 200 insertions(+), 45 deletions(-) diff --git a/src/lib/modules/audio/methods.ts b/src/lib/modules/audio/methods.ts index 7f3ba76..a4ef33c 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,9 @@ 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'), + uninstallWindowsVirtualCable: (): Promise => invoke('uninstall_windows_virtual_cable', { confirmed: true }), 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..f37afd8 100644 --- a/src/lib/modules/audio/types.ts +++ b/src/lib/modules/audio/types.ts @@ -82,6 +82,35 @@ 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; +} + 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..59c4c1b 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 virtualDevicesLabel = platform() === 'windows' ? 'Set up virtual microphone' : 'Add virtual device'; type MicrophoneNodeType = Node; let { id, data }: NodeProps = $props(); @@ -87,15 +87,13 @@ refresh()}> {#snippet footer(close)} - {#if supportsVirtualDevices} - { - close(); - goto('/virtual-devices'); - }} /> - {/if} + { + close(); + goto('/virtual-devices'); + }} /> {/snippet} {#if missing} diff --git a/src/lib/modules/flow/ui/output/speaker.svelte b/src/lib/modules/flow/ui/output/speaker.svelte index 008b946..20d7684 100644 --- a/src/lib/modules/flow/ui/output/speaker.svelte +++ b/src/lib/modules/flow/ui/output/speaker.svelte @@ -15,7 +15,7 @@ import { onDestroy, onMount } from 'svelte'; import { platform } from '@tauri-apps/plugin-os'; - const supportsVirtualDevices = platform() !== 'windows'; + const virtualDevicesLabel = platform() === 'windows' ? 'Set up virtual microphone' : 'Add virtual device'; type SpeakerNodeType = Node; let { id, data }: NodeProps = $props(); @@ -89,15 +89,13 @@ refresh()}> {#snippet footer(close)} - {#if supportsVirtualDevices} - { - close(); - goto('/virtual-devices'); - }} /> - {/if} + { + close(); + goto('/virtual-devices'); + }} /> {/snippet} {#if missing} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a15837f..9485fd4 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -73,9 +73,7 @@ {#snippet left()}
Pipelines - {#if !isWindows} - Virtual devices - {/if} + Virtual devices Wiki Settings diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 317d405..f11b2bf 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -1,6 +1,5 @@
{#snippet left()}
Pipelines - {#if !isWindows} - Virtual devices - {/if} + Virtual devices Wiki Settings @@ -118,14 +188,80 @@

Virtual Devices

- A virtual device is a system audio device that exists only in software — no hardware required. Apps can send audio to it or record from it just like - a real microphone or speaker. Each device appears as both an input and an output, so a pipeline can receive audio from one app and route it to - another. + {#if isWindows} + Windows uses the fixed VB-Audio VB-CABLE pair: send a Speaker output to CABLE Input, then select CABLE Output as the microphone in the other app. + Splitwave does not create arbitrary named Windows virtual devices. + {:else} + A virtual device is a system audio device that exists only in software - no hardware required. Apps can send audio to it or record from it just like + a real microphone or speaker. Each device appears as both an input and an output, so a pipeline can receive audio from one app and route it to + another. + {/if}

{#if isWindows} -

Virtual devices are not supported on Windows.

+
+
+
+

Virtual microphone

+

+ {#if windowsCableStatus?.state === 'notInstalled'} + Not installed + {:else if windowsCableStatus?.state === 'installedExternal'} + Installed externally — managed outside Splitwave + {:else if windowsCableStatus?.state === 'installedManaged'} + Installed by Splitwave + {:else if windowsCableStatus?.state === 'rebootRequired'} + Installation completed, but Windows must be restarted + {:else if windowsCableStatus?.state === 'partial'} + VB-CABLE is only partially available + {:else if windowsCableStatus?.state === 'unknownOwnership'} + VB-CABLE was detected, but Splitwave cannot safely determine who manages it + {:else if windowsCableStatus?.state === 'removalPendingReboot'} + VB-CABLE removal is pending a Windows restart + {:else} + Checking VB-CABLE status… + {/if} +

+
+
+ {#if windowsCableStatus?.state === 'notInstalled'} + + {:else if windowsCableStatus?.state === 'installedManaged'} + + {/if} + + +
+
+ + {#if windowsCableStatus?.renderEndpointName || windowsCableStatus?.captureEndpointName} +
+ {#if windowsCableStatus?.renderEndpointName} +

Send Splitwave audio to: {windowsCableStatus.renderEndpointName}

+ {/if} + {#if windowsCableStatus?.captureEndpointName} +

Select as microphone in another app: {windowsCableStatus.captureEndpointName}

+ {/if} +

Select the render endpoint in an existing Speaker output node. Splitwave does not create arbitrary named Windows virtual devices.

+
+ {/if} + + {#if windowsCableStatus?.installedVersion} +

Detected driver version: {windowsCableStatus.installedVersion}

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

{windowsCableStatus.detail}

+ {/if} +

Powered by VB-Audio VB-CABLE. VB-CABLE is third-party donationware.

+
+ {#if error} +

{error}

+ {/if} {/if} {#if !isLinux && !isWindows} @@ -142,7 +278,7 @@ {#if status?.installed} {:else} - {/if} diff --git a/src/routes/wiki/+page.svelte b/src/routes/wiki/+page.svelte index 0eecd80..aeaa2dc 100644 --- a/src/routes/wiki/+page.svelte +++ b/src/routes/wiki/+page.svelte @@ -1,9 +1,7 @@ + +
+ Pipelines + {#if showVirtualDevices} + Virtual devices + {/if} + Wiki + Settings +
diff --git a/src/lib/modules/audio/ui/index.ts b/src/lib/modules/audio/ui/index.ts index 9740aaa..0397d4e 100644 --- a/src/lib/modules/audio/ui/index.ts +++ b/src/lib/modules/audio/ui/index.ts @@ -2,3 +2,4 @@ export { default as ActivationButton } from './activation_button.svelte'; export { default as RunningTimer } from './running_timer.svelte'; export { default as LatencyBadge } from './latency_badge.svelte'; export { default as DriverUpdateBanner } from './driver_update_banner.svelte'; +export { default as WindowsVirtualMicrophoneModal } from './windows_virtual_microphone_modal.svelte'; diff --git a/src/lib/modules/audio/ui/windows_virtual_microphone_modal.svelte b/src/lib/modules/audio/ui/windows_virtual_microphone_modal.svelte new file mode 100644 index 0000000..6b371ab --- /dev/null +++ b/src/lib/modules/audio/ui/windows_virtual_microphone_modal.svelte @@ -0,0 +1,192 @@ + + +
+ {#if checking} +
Checking Windows audio…
+ {:else if needsRestart} +
+
+
+ +
+
+

Restart Windows to finish

+

+ VB-CABLE is present, but Windows has not exposed both audio endpoints yet. Splitwave will detect them automatically after restart. +

+
+
+ {#if status?.captureEndpointName || status?.renderEndpointName} +

+ Detected so far: {status.renderEndpointName ?? status.captureEndpointName} +

+ {/if} +
+ +
+
+ {:else if cannotManage} +
+
+ +
+

VB-CABLE needs attention

+

+ Splitwave found VB-CABLE but cannot safely verify its ownership. The driver will be preserved and not changed. +

+
+
+
+ +
+
+ {:else} +
+

Splitwave uses VB-CABLE to make your mix available as a microphone in other apps.

+ +
+
+
+ +
+ Splitwave +
+ +
+
+ +
+ CABLE Input +
+ +
+
+ +
+ CABLE Output +
+ +
+
+ +
+ Other apps +
+
+ +
+

VB-CABLE is third-party donationware by VB-Audio.

+

Windows administrator approval and a restart may be required.

+ +
+ + {#if issue} +

+ {issue} +

+ {/if} + +
+ + +
+
+ {/if} +
diff --git a/src/lib/modules/audio/windows_virtual_microphone.ts b/src/lib/modules/audio/windows_virtual_microphone.ts new file mode 100644 index 0000000..91f5863 --- /dev/null +++ b/src/lib/modules/audio/windows_virtual_microphone.ts @@ -0,0 +1,32 @@ +import { modalManager } from '$lib/modules/overlay/modal'; +import { audioStore } from './stores.svelte'; +import { methods } from './methods'; +import type { WindowsVirtualCableStatus } from './types'; +import WindowsVirtualMicrophoneModal from './ui/windows_virtual_microphone_modal.svelte'; + +export async function chooseWindowsVirtualMicrophoneOutput(): Promise { + let status: WindowsVirtualCableStatus | null = null; + try { + status = await methods.windowsVirtualCableStatus(); + } catch { + // The setup dialog owns status failures and presents them without leaking backend details. + } + + if (!status?.usable || !status.renderEndpointName) { + const title = status && status.state !== 'notInstalled' ? 'Finish virtual microphone setup' : 'Add virtual microphone'; + status = + (await modalManager.open( + title, + WindowsVirtualMicrophoneModal, + { + initialStatus: status, + canClose: false, + size: 'md' + } + )) ?? null; + } + + if (!status?.usable || !status.renderEndpointName) return null; + await audioStore.refreshOutputDevices().catch(() => {}); + return status.renderEndpointName; +} diff --git a/src/lib/modules/flow/ui/input/microphone.svelte b/src/lib/modules/flow/ui/input/microphone.svelte index 59c4c1b..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 virtualDevicesLabel = platform() === 'windows' ? 'Set up virtual microphone' : 'Add virtual device'; + const isWindows = platform() === 'windows'; type MicrophoneNodeType = Node; let { id, data }: NodeProps = $props(); @@ -87,13 +87,15 @@ refresh()}> {#snippet footer(close)} - { - close(); - goto('/virtual-devices'); - }} /> + {#if !isWindows} + { + close(); + goto('/virtual-devices'); + }} /> + {/if} {/snippet} {#if missing} diff --git a/src/lib/modules/flow/ui/output/speaker.svelte b/src/lib/modules/flow/ui/output/speaker.svelte index 20d7684..6c865eb 100644 --- a/src/lib/modules/flow/ui/output/speaker.svelte +++ b/src/lib/modules/flow/ui/output/speaker.svelte @@ -14,8 +14,10 @@ import { onNodeAction } from '$lib/modules/flow/utils'; import { onDestroy, onMount } from 'svelte'; import { platform } from '@tauri-apps/plugin-os'; + import { chooseWindowsVirtualMicrophoneOutput } from '$lib/modules/audio/windows_virtual_microphone'; - const virtualDevicesLabel = platform() === 'windows' ? 'Set up virtual microphone' : 'Add virtual device'; + const isWindows = platform() === 'windows'; + const virtualDevicesLabel = isWindows ? 'Use virtual microphone' : 'Add virtual device'; type SpeakerNodeType = Node; let { id, data }: NodeProps = $props(); @@ -35,6 +37,15 @@ await audioStore.refreshOutputDevices(); } + async function openVirtualDevice() { + if (!isWindows) { + await goto('/virtual-devices'); + return; + } + const deviceId = await chooseWindowsVirtualMicrophoneOutput(); + if (deviceId) setDevice(deviceId); + } + let unlistenRefresh: (() => void) | undefined; onMount(() => { unlistenRefresh = onNodeAction(id, 'refresh', () => refresh()); @@ -94,7 +105,7 @@ icon={Add} onclick={() => { close(); - goto('/virtual-devices'); + void openVirtualDevice(); }} /> {/snippet} diff --git a/src/lib/modules/flow/ui/sidebar.svelte b/src/lib/modules/flow/ui/sidebar.svelte index a754916..ec7495e 100644 --- a/src/lib/modules/flow/ui/sidebar.svelte +++ b/src/lib/modules/flow/ui/sidebar.svelte @@ -1,9 +1,13 @@