audiofox 1.1.0

Cross platform audio capture made easy!
Documentation
// SPDX-License-Identifier: GPL-2.0-or-later WITH Classpath-exception-2.0
// Audiofox, the audio capturer!
// Copyright (c) 2026 foxberries

use std::ffi::{OsStr, c_void};
use std::os::windows::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use std::{iter, ptr, slice};

use anyhow::{Context, bail};
use log::{debug, warn};
use wasapi::{DeviceEnumerator, Direction};
use windows::Win32::Foundation::{CloseHandle, MAX_PATH};
use windows::Win32::Storage::FileSystem::{
    GetFileVersionInfoSizeW, GetFileVersionInfoW, VerQueryValueW,
};
use windows::Win32::System::Threading::{
    OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW,
};
use windows::core::{PCWSTR, PWSTR};

use crate::SourceName;
use crate::common::result::Result;
use crate::common::source::{AudioSource, AudioSources};

/// List applications capable of streaming audio.
pub fn list() -> Result<AudioSources> {
    let mut sources = Vec::new();

    // Get the device enumerator
    let enumerator = DeviceEnumerator::new().context("Failed to get device enumerator")?;

    // For each device in the collection
    for device in &enumerator.get_device_collection(&Direction::Render)
        .context("Failed to get audio device collection")?
    {
        let device = device.context("Failed to get audio endpoint device")?;

        // Get the device's session enumerator
        let session_enum = device.get_iaudiosessionmanager()
            .context("Failed to get audio session manager")?
            .get_audiosessionenumerator()
            .context("Failed to get audio session enumerator")?;

        // For each session of the enumerator
        for i in 0..session_enum.get_count()
            .context("Failed to get audio session count")?
        {
            // Get the session's process ID
            let session = session_enum.get_session(i)
                .context("Failed to get audio session control")?;

            let id = session.get_process_id()
                .context("Failed to get PID of audio session")?;

            // If this session is not owned by the system, get its display name
            if id != 0 {
                match exe_path(id) {
                    // Get the friendly name with a fallback to the executable name
                    Ok(path) => {
                        sources.push(AudioSource {
                            id: id.into(),
                            name: friendly_name(&path).or_else(|e| -> Result<SourceName> {
                                let name = path.file_name()
                                    .context("Process path should never end in '..'")?
                                    .display().to_string();
                                debug!("using name={name} for source id={id}: {e}");
                                Ok(name.into())
                            })?
                        });
                    }
                    // We cannot continue without the process path
                    Err(e) => {
                        warn!("skipping process id={id}: {e}");
                        break;
                    }
                }
            }
        }
    }
    Ok(sources.into())
}

/// Get the executable path of a process by its ID.
fn exe_path(pid: u32) -> Result<PathBuf> {
    unsafe {
        // Get a handle to the process
        let handle = OpenProcess(
            PROCESS_QUERY_LIMITED_INFORMATION,
            false,
            pid
        ).context("Failed to access process object")?;

        // Allocate a buffer for the path
        let mut path_buffer = vec![0u16; MAX_PATH as usize];
        let mut buffer_size = MAX_PATH;

        // Query for the full path to the executable
        QueryFullProcessImageNameW(
            handle,
            PROCESS_NAME_WIN32,
            PWSTR(path_buffer.as_mut_ptr()),
            &raw mut buffer_size,
        ).context("Failed to query process image name")?;

        // Close the handle
        CloseHandle(handle).context("Handle should never be invalid")?;

        // Convert the wide string to a PathBuf
        let path = String::from_utf16_lossy(&path_buffer[..buffer_size as usize]);
        Ok(path.into())
    }
}

/// Get the localized display name of a process by its path.
fn friendly_name(path: &Path) -> Result<SourceName> {
    unsafe {
        // Format path accordingly
        let exe_path: Vec<u16> = path.as_os_str().encode_wide().chain(iter::once(0)).collect();

        // Get version information size
        let info_size = GetFileVersionInfoSizeW(PCWSTR(exe_path.as_ptr()), None);

        if info_size == 0 {
            bail!("No version information available for this process");
        }

        // Allocate a byte buffer and populate with version information
        let mut info_buffer = vec![0u8; info_size as usize];
        GetFileVersionInfoW(
            PCWSTR(exe_path.as_ptr()),
            None,
            info_size,
            info_buffer.as_mut_ptr().cast::<c_void>(),
        ).context("Failed to get file version information")?;

        // Extract the language and code page pairs identified from the version information
        let translation_query = wide_string("\\VarFileInfo\\Translation");
        let mut translation_ptr = ptr::null_mut();
        let mut translation_size = 0u32;
        let result = VerQueryValueW(
            info_buffer.as_ptr().cast::<c_void>(),
            PCWSTR(translation_query.as_ptr()),
            &raw mut translation_ptr,
            &raw mut translation_size,
        );

        if !result.as_bool() || translation_size == 0 {
            bail!("Failed to get locale information");
        }

        // Combine the language ID and the code page ID to create the lang-codepage name
        // Low 16 bits are the language ID; high 16 bits are the code page ID
        let translation = *translation_ptr.cast::<u32>();
        let locale = format!(
            "{:04x}{:04x}",
            translation as u16,
            (translation >> 16) as u16
        );

        // Query FileDescription using the extracted language code to get the display name
        let query_path = wide_string(&format!("\\StringFileInfo\\{locale}\\FileDescription"));
        let mut description_ptr = ptr::null_mut();
        let mut description_size = 0;
        let result = VerQueryValueW(
            info_buffer.as_ptr().cast::<c_void>(),
            PCWSTR(query_path.as_ptr()),
            &raw mut description_ptr,
            &raw mut description_size,
        );

        if !result.as_bool() || description_size == 0 {
            bail!("Failed to get display name from FileDescription");
        }

        // Convert the raw bytes into a String
        let friendly_name = String::from_utf16_lossy(slice::from_raw_parts(
            description_ptr.cast::<u16>(),
            description_size.saturating_sub(1) as usize,
        ));

        Ok(friendly_name.into())
    }
}

/// Create a null-terminated UTF-16 string for use in WinAPI calls.
fn wide_string(s: &str) -> Vec<u16> {
    OsStr::new(s).encode_wide().chain(iter::once(0)).collect()
}