Skip to main content

nice_plug_install_xtask/
lib.rs

1use anyhow::{Context, Result, bail};
2use cargo_metadata::{CrateType, MetadataCommand};
3use fs_extra::{
4    dir::{CopyOptions as DirCopyOptions, copy as copy_dir},
5    file::{CopyOptions as FileCopyOptions, copy as copy_file},
6};
7use std::{
8    collections::HashSet,
9    fs,
10    path::{Path, PathBuf},
11};
12
13pub fn main() -> Result<()> {
14    let args = std::env::args().skip(1);
15    main_with_args("cargo xtask", args)
16}
17
18pub fn main_with_args(command_name: &str, args: impl IntoIterator<Item = String>) -> Result<()> {
19    let mut args = args.into_iter();
20    let command = args
21        .next()
22        .with_context(|| format!("Missing command name {command_name}\n",))?;
23
24    match command.as_str() {
25        "bundle" | "bundle-universal" => install(args),
26        "help" | "-h" | "--help" => {
27            println!("{HELP}");
28            Ok(())
29        }
30        unknown => bail!("unknown xtask command '{unknown}'\n\n{HELP}"),
31    }
32}
33
34fn install(args: impl Iterator<Item = String>) -> Result<()> {
35    println!("\n * Installing...");
36    let metadata = MetadataCommand::new()
37        .manifest_path("Cargo.toml")
38        .exec()
39        .context("failed to read cargo metadata")?;
40    let bundle_dir = metadata.target_directory.as_std_path().join("bundled");
41    if !bundle_dir.exists() {
42        bail!(
43            "bundle directory does not exist: {}; build plugins before installing",
44            bundle_dir.display()
45        );
46    }
47    let (package_names, args) = split_bundle_args(args)?;
48    let bundle_names = package_names
49        .iter()
50        .map(|package| package.replace('-', "_"))
51        .collect::<HashSet<_>>();
52    let exported_formats = exported_formats(&metadata, &package_names, &args)?;
53    let bundles = fs::read_dir(&bundle_dir)
54        .with_context(|| format!("failed to read {}", bundle_dir.display()))?
55        .filter_map(|entry| entry.ok().map(|entry| entry.path()))
56        .filter(|path| {
57            matches!(
58                path.extension().and_then(|ext| ext.to_str()),
59                Some("clap" | "vst3" | "component")
60            ) && (path.extension().and_then(|extension| extension.to_str()) == Some("component")
61                || path
62                    .file_stem()
63                    .and_then(|stem| stem.to_str())
64                    .is_some_and(|stem| {
65                        package_names.contains(stem) || bundle_names.contains(stem)
66                    }))
67                && path
68                    .extension()
69                    .and_then(|extension| extension.to_str())
70                    .is_some_and(|extension| exported_formats.contains(extension))
71        })
72        .collect::<Vec<_>>();
73
74    if bundles.is_empty() {
75        bail!(
76            "no built plugin bundles matched {} in {}",
77            package_names.iter().cloned().collect::<Vec<_>>().join(", "),
78            bundle_dir.display()
79        );
80    }
81
82    for source in bundles {
83        let extension = source.extension().and_then(|ext| ext.to_str()).unwrap();
84        let Some(install_dir) = install_dir(extension) else {
85            continue;
86        };
87        if !install_dir.exists() {
88            println!(" ! Don't exists {}", install_dir.display());
89            continue;
90        }
91        let destination = install_dir.join(source.file_name().context("bundle has no file name")?);
92        if destination.exists() {
93            println!(" - Deleting '{}'", destination.display());
94            if destination.is_dir() {
95                fs::remove_dir_all(&destination)
96                    .with_context(|| format!("failed to remove {}", destination.display()))?;
97            } else {
98                fs::remove_file(&destination)
99                    .with_context(|| format!("failed to remove {}", destination.display()))?;
100            }
101        }
102        if source.is_dir() {
103            copy_dir(
104                &source,
105                &destination,
106                &DirCopyOptions::new().content_only(true),
107            )
108            .with_context(|| {
109                format!(
110                    "failed to copy {} to {}",
111                    source.display(),
112                    destination.display()
113                )
114            })?;
115        } else {
116            copy_file(&source, &destination, &FileCopyOptions::new()).with_context(|| {
117                format!(
118                    "failed to copy {} to {}",
119                    source.display(),
120                    destination.display()
121                )
122            })?;
123        }
124        println!(
125            " + Installing {} to '{}'",
126            source.file_name().unwrap().display(),
127            destination.display()
128        );
129    }
130    println!();
131    Ok(())
132}
133
134fn split_bundle_args(args: impl Iterator<Item = String>) -> Result<(HashSet<String>, Vec<String>)> {
135    let mut args = args.peekable();
136    let mut packages = Vec::new();
137    if args.peek().map(|s| s.as_str()) == Some("-p") {
138        while args.peek().map(|s| s.as_str()) == Some("-p") {
139            packages.push(
140                args.nth(1)
141                    .with_context(|| format!("Missing package name after -p\n\n{HELP}"))?,
142            );
143        }
144    } else {
145        packages.push(
146            args.next()
147                .with_context(|| format!("Missing package name\n\n{HELP}"))?,
148        );
149    };
150    let other_args: Vec<_> = args.collect();
151
152    Ok((packages.into_iter().collect(), other_args))
153}
154
155fn exported_formats(
156    metadata: &cargo_metadata::Metadata,
157    package_names: &HashSet<String>,
158    args: &[String],
159) -> Result<HashSet<&'static str>> {
160    let mut formats = HashSet::new();
161    let mut profile = "debug";
162    for (index, arg) in args.iter().enumerate() {
163        if arg == "--release" {
164            profile = "release";
165        } else if arg == "--profile" {
166            profile = args.get(index + 1).context("missing profile name")?;
167        } else if let Some(value) = arg.strip_prefix("--profile=") {
168            profile = value;
169        }
170    }
171    let mut target_dir = metadata.target_directory.as_std_path().to_path_buf();
172    for (index, arg) in args.iter().enumerate() {
173        if arg == "--target" {
174            if let Some(target) = args.get(index + 1) {
175                target_dir.push(target);
176            }
177        } else if let Some(target) = arg.strip_prefix("--target=") {
178            target_dir.push(target);
179        }
180    }
181    target_dir.push(profile);
182
183    for package in metadata
184        .packages
185        .iter()
186        .filter(|package| package_names.contains(package.name.as_str()))
187    {
188        for target in &package.targets {
189            if !target.crate_types.contains(&CrateType::CDyLib) {
190                continue;
191            }
192            let library = target_dir.join(native_library_name(&target.name));
193            if library.exists() {
194                if exported(&library, "clap_entry")? {
195                    formats.insert("clap");
196                }
197                if exported(&library, "GetPluginFactory")? {
198                    formats.insert("vst3");
199                }
200                if exported(&library, "nice_au2_metadata")? {
201                    formats.insert("component");
202                }
203            }
204        }
205    }
206    Ok(formats)
207}
208
209fn install_dir(extension: &str) -> Option<PathBuf> {
210    match extension {
211        "clap" => install_dirs::CLAP,
212        "vst3" => install_dirs::VST3,
213        "component" => install_dirs::COMPONENTS,
214        _ => None,
215    }
216    .map(PathBuf::from)
217}
218
219fn native_library_name(target: &str) -> String {
220    let target = target.replace('-', "_");
221    if cfg!(target_os = "windows") {
222        format!("{target}.dll")
223    } else if cfg!(target_os = "macos") {
224        format!("lib{target}.dylib")
225    } else {
226        format!("lib{target}.so")
227    }
228}
229
230fn exported(binary: &Path, symbol: &str) -> Result<bool> {
231    let bytes = fs::read(binary).with_context(|| format!("failed to read {}", binary.display()))?;
232    match goblin::Object::parse(&bytes)? {
233        goblin::Object::Elf(object) => Ok(object.dynsyms.iter().any(|entry| {
234            !entry.is_import() && object.dynstrtab.get_at(entry.st_name) == Some(symbol)
235        })),
236        goblin::Object::Mach(object) => {
237            let object = match object {
238                goblin::mach::Mach::Fat(arches) => match arches.get(0)? {
239                    goblin::mach::SingleArch::MachO(object) => object,
240                    goblin::mach::SingleArch::Archive(_) => {
241                        bail!("{} contains a Mach-O archive", binary.display())
242                    }
243                },
244                goblin::mach::Mach::Binary(object) => object,
245            };
246            let symbol = format!("_{symbol}");
247            Ok(object.exports()?.iter().any(|entry| entry.name == symbol))
248        }
249        goblin::Object::PE(object) => Ok(object
250            .exports
251            .iter()
252            .any(|entry| entry.name == Some(symbol))),
253        object => bail!("unsupported object type: {object:?}"),
254    }
255}
256
257#[cfg(target_os = "macos")]
258mod install_dirs {
259    pub const CLAP: Option<&str> = Some(concat!(env!("HOME"), "/Library/Audio/Plug-Ins/CLAP"));
260    pub const VST3: Option<&str> = Some(concat!(env!("HOME"), "/Library/Audio/Plug-Ins/VST3"));
261    pub const COMPONENTS: Option<&str> =
262        Some(concat!(env!("HOME"), "/Library/Audio/Plug-Ins/Components"));
263}
264
265#[cfg(target_os = "windows")]
266mod install_dirs {
267    pub const CLAP: Option<&str> = Some(concat!(env!("LOCALAPPDATA"), "\\Programs\\Common\\CLAP"));
268    pub const VST3: Option<&str> = Some(concat!(env!("LOCALAPPDATA"), "\\Programs\\Common\\VST3"));
269    pub const COMPONENTS: Option<&str> = None;
270}
271
272#[cfg(not(any(target_os = "macos", target_os = "windows")))]
273mod install_dirs {
274    pub const CLAP: Option<&str> = Some(concat!(env!("HOME"), "/.clap"));
275    pub const VST3: Option<&str> = Some(concat!(env!("HOME"), "/.vst3"));
276    pub const COMPONENTS: Option<&str> = None;
277}
278
279const HELP: &str = "Usage:
280  cargo xtask bundle <package> [cargo build options]
281
282Installs only the selected package's exported CLAP, VST3, and AU bundles from target/bundled/ into the current user's plugin directories.";