audiofox 1.0.0

Cross platform audio capture made easy!
// 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::{iter, ptr, slice};

use anyhow::{Context, bail};
use log::debug;
use sysinfo::{Pid, Process, ProcessRefreshKind, RefreshKind, System};
use wasapi::{DeviceEnumerator, Direction};
use windows::Win32::Storage::FileSystem::{
    GetFileVersionInfoSizeW, GetFileVersionInfoW, VerQueryValueW,
};
use windows::core::PCWSTR;

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 and system information
    let enumerator = DeviceEnumerator::new()
        .context("Failed to get device enumerator")?;

    let system = System::new_with_specifics(
        RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
    );

    // 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 {
                if let Some(process) = system.process(Pid::from(id as usize)) {
                    sources.push(AudioSource {
                        id: id.into(),
                        name: get_display_name(process).unwrap_or_else(|e| {
                            let proc = process.name().display();
                            debug!("failed to get display name for process {proc}, using '{e}'");
                            proc.to_string()
                        }).into(),
                    });
                }
            }
        }
    }
    Ok(sources.into())
}

/// Get the localized display name of a [`Process`].
fn get_display_name(process: &Process) -> Result<String> {
    unsafe {
        // Get exe path directly from process and format accordingly
        let exe_path: Vec<u16> = process.exe().context("Failed to get path to process")?
            .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 language and code-pair 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)
    }
}

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