nice_plug_install_xtask/
lib.rs1use 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.";