tympan-apo 0.1.0

Rust framework for Windows Audio Processing Objects (APOs)
Documentation
//! INF file generation for APO deployment.
//!
//! `regsvr32` is enough for hand-installation and CI testing, but
//! production APO drops typically ship an INF that integrates with
//! the Windows componentization model. This module emits a minimal
//! INF the consumer crate's build script can write to disk and
//! hand to the Windows driver toolchain.
//!
//! The generator deliberately stops short of audio-endpoint
//! binding (the `FxProperties` registry key under
//! `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\MMDevices\Audio`)
//! — that lives in a separate helper and requires a target
//! endpoint GUID the framework cannot supply.
//!
//! ## Example
//!
//! ```rust
//! # use tympan_apo::{Clsid, inf::{generate, InfConfig}};
//! let inf = generate(&InfConfig {
//!     provider: "Acme",
//!     friendly_name: "Acme Echo Reducer",
//!     clsid: Clsid::from_u128(0x12345678_1234_5678_1234_567812345678),
//!     dll_filename: "acme_echo.dll",
//!     major_version: 1,
//!     minor_version: 0,
//!     driver_date: "01/01/2026",
//! });
//! assert!(inf.contains("[Version]"));
//! ```

extern crate alloc;

use alloc::format;
use alloc::string::String;
use core::fmt::Write as _;

use crate::clsid::Clsid;

/// Static configuration for one INF file.
///
/// All fields are `'static` — the generated INF is a `String`
/// returned by value, so the inputs need to outlive the call but
/// not the output.
#[derive(Copy, Clone, Debug)]
pub struct InfConfig {
    /// `Provider` field in the INF `[Version]` section. Vendor or
    /// publisher name.
    pub provider: &'static str,
    /// Friendly name shown in the Sound Settings UI; also used as
    /// the CLSID's default registry value.
    pub friendly_name: &'static str,
    /// CLSID the INF will register.
    pub clsid: Clsid,
    /// Filename of the APO cdylib. Just the leaf
    /// (e.g. `"my_apo.dll"`); the INF places the file under
    /// `%SystemRoot%\System32` via `DestinationDirs`.
    pub dll_filename: &'static str,
    /// `u32MajorVersion` for `APO_REG_PROPERTIES` cross-reference.
    pub major_version: u32,
    /// `u32MinorVersion` for `APO_REG_PROPERTIES` cross-reference.
    pub minor_version: u32,
    /// `DriverVer` date in `MM/DD/YYYY` form. The Windows driver
    /// toolchain rejects INFs whose `DriverVer` is older than the
    /// running OS's policy floor, so producers typically set this
    /// to the build date.
    pub driver_date: &'static str,
}

/// Build a minimal INF from `config`.
///
/// The output covers:
///
/// - `[Version]`: standard MEDIA class, the provider name, a
///   `DriverVer` derived from `config`.
/// - `[DestinationDirs]`: places the cdylib under
///   `%SystemRoot%\System32` (`DIRID 11`).
/// - `[Manufacturer]` / `[Models]`: declares one model whose name
///   matches `friendly_name`.
/// - `[Install]` section: copies the cdylib, then adds the CLSID
///   subtree under `HKLM\SOFTWARE\Classes\CLSID\{<clsid>}` via
///   `[AddReg]` directives (matching what `DllRegisterServer`
///   writes to HKCU at runtime, but at the machine scope).
/// - `[Strings]`: localised provider / model names.
///
/// The result is opinionated and intended as a starting point.
/// Real ships frequently customise `[Manufacturer]` flags, the
/// catalog file reference, and the install-time co-installer
/// chain — users are expected to hand-tune.
#[must_use]
pub fn generate(config: &InfConfig) -> String {
    let mut out = String::with_capacity(2048);
    let _ = writeln!(
        out,
        "; Generated by tympan-apo — INF for {}",
        config.friendly_name
    );
    out.push_str(";\n");
    out.push_str("; This is a starting template. Real deployments typically need\n");
    out.push_str("; hand-tuning of [Manufacturer] flags, CatalogFile, and the\n");
    out.push_str("; install-time co-installer chain.\n\n");

    // [Version]
    out.push_str("[Version]\n");
    out.push_str("Signature   = \"$WINDOWS NT$\"\n");
    out.push_str("Class       = MEDIA\n");
    out.push_str("ClassGuid   = {4d36e96c-e325-11ce-bfc1-08002be10318}\n");
    out.push_str("Provider    = %ProviderName%\n");
    let _ = writeln!(
        out,
        "DriverVer   = {},{}.{}.0.0",
        config.driver_date, config.major_version, config.minor_version
    );
    out.push('\n');

    // [DestinationDirs]
    // DIRID 11 == %SystemRoot%\System32. The audio engine loads
    // APO cdylibs from anywhere on the system path; placing the
    // file under System32 keeps it discoverable.
    out.push_str("[DestinationDirs]\n");
    out.push_str("DefaultDestDir = 11\n");
    out.push('\n');

    // [SourceDisksNames] / [SourceDisksFiles] declare the install
    // medium and the source of the DLL.
    out.push_str("[SourceDisksNames]\n");
    out.push_str("1 = %DiskName%\n");
    out.push('\n');
    out.push_str("[SourceDisksFiles]\n");
    let _ = writeln!(out, "{} = 1", config.dll_filename);

    // [Manufacturer] / [Models]
    out.push_str("[Manufacturer]\n");
    out.push_str("%ProviderName% = ApoModels, NTamd64\n\n");
    out.push_str("[ApoModels.NTamd64]\n");
    let _ = writeln!(
        out,
        "%ModelName% = ApoInstall, ROOT\\{}",
        clsid_id_string(&config.clsid)
    );

    // [ApoInstall]
    out.push_str("[ApoInstall]\n");
    out.push_str("CopyFiles = ApoFiles\n");
    out.push_str("AddReg    = ApoAddReg\n\n");

    out.push_str("[ApoFiles]\n");
    let _ = writeln!(out, "{}", config.dll_filename);

    // [ApoAddReg] — write the CLSID subtree to
    // HKLM\SOFTWARE\Classes\CLSID\{<clsid>}\InprocServer32.
    // Mirrors the HKCU write the runtime DllRegisterServer
    // performs.
    let clsid_brace = format!("{{{}}}", clsid_no_braces(&config.clsid));
    out.push_str("[ApoAddReg]\n");
    let _ = writeln!(out, "HKCR,CLSID\\{},,0,%ModelName%", clsid_brace);
    let _ = writeln!(
        out,
        "HKCR,CLSID\\{}\\InprocServer32,,0,\"%11%\\{}\"",
        clsid_brace, config.dll_filename
    );
    let _ = writeln!(
        out,
        "HKCR,CLSID\\{}\\InprocServer32,ThreadingModel,0,\"Both\"",
        clsid_brace
    );

    // [Strings]
    out.push_str("[Strings]\n");
    let _ = writeln!(out, "ProviderName = \"{}\"", escape_inf(config.provider));
    let _ = writeln!(
        out,
        "ModelName    = \"{}\"",
        escape_inf(config.friendly_name)
    );
    out.push_str("DiskName     = \"tympan-apo cdylib\"\n");

    out
}

/// CLSID rendered as a hex string without surrounding braces or
/// hyphens, suitable for the `ROOT\<id>` device-id slot in
/// `[Models]`.
fn clsid_id_string(c: &Clsid) -> String {
    let mut s = String::with_capacity(32);
    let _ = write!(s, "{:08X}", c.data1);
    let _ = write!(s, "{:04X}", c.data2);
    let _ = write!(s, "{:04X}", c.data3);
    for b in c.data4 {
        let _ = write!(s, "{b:02X}");
    }
    s
}

/// CLSID rendered without the surrounding braces but with the
/// canonical dash separators, suitable for INF `AddReg` lines.
fn clsid_no_braces(c: &Clsid) -> String {
    let full = format!("{c}");
    // `Clsid::Display` produces `{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}`;
    // strip the leading and trailing braces.
    full.trim_start_matches('{').trim_end_matches('}').into()
}

/// Quote-escape a string for embedding in an INF `[Strings]`
/// value. INF strings are double-quote delimited; the only
/// in-string escape is `""` for a literal `"`.
fn escape_inf(s: &str) -> String {
    s.replace('"', "\"\"")
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample_config() -> InfConfig {
        InfConfig {
            provider: "tympan-apo",
            friendly_name: "Test APO",
            clsid: Clsid::from_u128(0x12345678_1234_5678_1234_567812345678),
            dll_filename: "test_apo.dll",
            major_version: 1,
            minor_version: 0,
            driver_date: "01/01/2026",
        }
    }

    #[test]
    fn generated_inf_contains_required_sections() {
        let inf = generate(&sample_config());
        for section in [
            "[Version]",
            "[DestinationDirs]",
            "[SourceDisksNames]",
            "[SourceDisksFiles]",
            "[Manufacturer]",
            "[ApoInstall]",
            "[ApoFiles]",
            "[ApoAddReg]",
            "[Strings]",
        ] {
            assert!(
                inf.contains(section),
                "missing section {section} in generated INF:\n{inf}"
            );
        }
    }

    #[test]
    fn generated_inf_embeds_clsid_and_dll_filename() {
        let inf = generate(&sample_config());
        // Brace-delimited CLSID appears in the AddReg lines.
        assert!(
            inf.contains("CLSID\\{12345678-1234-5678-1234-567812345678}"),
            "expected brace-delimited CLSID in INF:\n{inf}"
        );
        // DLL filename appears in CopyFiles and InprocServer32 default value.
        assert!(inf.contains("test_apo.dll"));
        assert!(inf.contains("%11%\\test_apo.dll"));
    }

    #[test]
    fn generated_inf_uses_default_threading_model_both() {
        let inf = generate(&sample_config());
        assert!(
            inf.contains("ThreadingModel,0,\"Both\""),
            "expected ThreadingModel = Both in InprocServer32"
        );
    }

    #[test]
    fn generated_inf_records_driver_version_string() {
        let inf = generate(&sample_config());
        assert!(
            inf.contains("DriverVer   = 01/01/2026,1.0.0.0"),
            "expected DriverVer with major.minor.0.0 in INF:\n{inf}"
        );
    }

    #[test]
    fn clsid_no_braces_strips_outer_braces() {
        let c = Clsid::from_u128(0xAABBCCDD_EEFF_0011_2233_445566778899);
        assert_eq!(clsid_no_braces(&c), "AABBCCDD-EEFF-0011-2233-445566778899");
    }

    #[test]
    fn clsid_id_string_is_continuous_hex() {
        let c = Clsid::from_u128(0x01234567_89AB_CDEF_0123_456789ABCDEF);
        assert_eq!(clsid_id_string(&c), "0123456789ABCDEF0123456789ABCDEF");
    }

    #[test]
    fn escape_inf_doubles_internal_quotes() {
        assert_eq!(escape_inf(r#"He said "hi""#), r#"He said ""hi"""#);
        assert_eq!(escape_inf("nothing to escape"), "nothing to escape");
    }
}