#[cfg(target_os = "macos")]
use super::PkgFormat;
#[cfg(target_os = "macos")]
use crate::install_scope::PkgScope;
#[cfg(target_os = "macos")]
use crate::pace_sign_aax_macos;
use crate::{Config, PluginDef, Res, codesign_bundle};
#[cfg(target_os = "macos")]
use crate::{MacosPackagingConfig, copy_dir_recursive};
#[cfg(target_os = "macos")]
use std::fmt::Write;
use std::fs;
use std::path::Path;
#[cfg(target_os = "macos")]
use std::path::PathBuf;
#[cfg(target_os = "macos")]
use std::process::Command;
pub(crate) fn lv2_slug(name: &str) -> String {
truce_utils::slugify(name)
}
pub(crate) fn stage_lv2(
root: &Path,
p: &PluginDef,
staging: &Path,
identity: &str,
target: Option<&str>,
) -> Res {
let built = crate::release_lib_for_target(root, &format!("{}_lv2", p.dylib_stem()), target);
if !built.exists() {
return Err(format!("Missing: {}", built.display()).into());
}
let target_dir = truce_build::target_dir(root);
let sidecar_dir = target_dir.join("lv2-meta").join(&p.crate_name);
let manifest_ttl = sidecar_dir.join("manifest.ttl");
let plugin_ttl = sidecar_dir.join("plugin.ttl");
if !manifest_ttl.exists() || !plugin_ttl.exists() {
return Err(format!(
"no LV2 metadata sidecar at {} for {}. \
`derive(Params)` writes this during the cdylib's compile; \
missing it means either the params struct uses `#[nested]` \
(unsupported for the compile-time TTL path) or the plugin \
crate isn't listed under `[[plugin]]` in truce.toml.",
sidecar_dir.display(),
p.name,
)
.into());
}
let slug = lv2_slug(&p.name);
let bundle = staging.join(format!("{slug}.lv2"));
let _ = fs::remove_dir_all(&bundle);
fs::create_dir_all(&bundle)?;
let bin_ext = if cfg!(target_os = "windows") {
"dll"
} else {
"so"
};
let bin_name = format!("{slug}.{bin_ext}");
let bin_path = bundle.join(&bin_name);
fs::copy(&built, &bin_path)?;
fs::copy(&manifest_ttl, bundle.join("manifest.ttl"))?;
fs::copy(&plugin_ttl, bundle.join("plugin.ttl"))?;
codesign_bundle(&bin_path.to_string_lossy(), identity, false)?;
Ok(())
}
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
pub(crate) fn stage_clap(
root: &Path,
p: &PluginDef,
config: &Config,
staging: &Path,
identity: &str,
target: Option<&str>,
) -> Res {
#[cfg(not(target_os = "macos"))]
let dylib = crate::release_lib_for_target(root, &format!("{}_clap", p.dylib_stem()), target);
#[cfg(target_os = "macos")]
let dylib = {
let _ = target;
crate::release_bundle_bin(root, &p.dylib_stem(), "_clap")
};
if !dylib.exists() {
return Err(format!("Missing: {}", dylib.display()).into());
}
let bundle = staging.join(format!("{}.clap", p.file_stem()));
#[cfg(target_os = "macos")]
{
let macos_dir = bundle.join("Contents/MacOS");
fs::create_dir_all(&macos_dir)?;
let exec_name = p.file_stem();
fs::copy(&dylib, macos_dir.join(&exec_name))?;
let plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>{exec_name}</string>
<key>CFBundleIdentifier</key>
<string>{vendor_id}.{bundle_id}</string>
<key>CFBundleName</key>
<string>{display_name}</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>"#,
display_name = p.name,
bundle_id = p.bundle_id,
vendor_id = config.vendor.id,
);
fs::write(bundle.join("Contents/Info.plist"), &plist)?;
codesign_bundle(bundle.to_str().unwrap(), identity, false)?;
}
#[cfg(not(target_os = "macos"))]
{
fs::copy(&dylib, &bundle)?;
codesign_bundle(bundle.to_str().unwrap(), identity, false)?;
}
Ok(())
}
pub(crate) fn stage_vst3(
root: &Path,
p: &PluginDef,
config: &Config,
staging: &Path,
target: Option<&str>,
) -> Res {
#[cfg(not(target_os = "macos"))]
let dylib = crate::release_lib_for_target(root, &format!("{}_vst3", p.dylib_stem()), target);
#[cfg(target_os = "macos")]
let dylib = crate::release_bundle_bin(root, &p.dylib_stem(), "_vst3");
if !dylib.exists() {
return Err(format!("Missing: {}", dylib.display()).into());
}
let bundle = staging.join(format!("{}.vst3", p.file_stem()));
#[cfg(target_os = "macos")]
{
let macos_dir = bundle.join("Contents/MacOS");
fs::create_dir_all(&macos_dir)?;
let exec_name = p.file_stem();
fs::copy(&dylib, macos_dir.join(&exec_name))?;
let plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>{exec_name}</string>
<key>CFBundleIdentifier</key>
<string>{vendor_id}.{bundle_id}</string>
<key>CFBundleName</key>
<string>{display_name}</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>"#,
display_name = p.name,
bundle_id = p.bundle_id,
vendor_id = config.vendor.id,
);
fs::write(bundle.join("Contents/Info.plist"), &plist)?;
codesign_bundle(
bundle.to_str().unwrap(),
&crate::application_identity(),
false,
)?;
}
#[cfg(any(target_os = "linux", target_os = "windows"))]
{
let _ = config; let triple: &str = match target {
Some(t) => t,
None => truce_build::host_triple(),
};
let arch_dir = bundle.join("Contents").join(vst3_arch_subdir(triple));
fs::create_dir_all(&arch_dir)?;
let inner_filename = format!("{}.{}", p.file_stem(), vst3_inner_extension(triple));
fs::copy(&dylib, arch_dir.join(inner_filename))?;
}
#[cfg(target_os = "macos")]
let _ = target; Ok(())
}
#[cfg(any(target_os = "linux", target_os = "windows"))]
fn vst3_arch_subdir(triple: &str) -> &'static str {
match triple {
"x86_64-unknown-linux-gnu" | "x86_64-unknown-linux-musl" => "x86_64-linux",
"aarch64-unknown-linux-gnu" | "aarch64-unknown-linux-musl" => "aarch64-linux",
"x86_64-pc-windows-msvc" | "x86_64-pc-windows-gnu" => "x86_64-win",
"aarch64-pc-windows-msvc" => "aarch64-win",
_ => "unknown",
}
}
#[cfg(any(target_os = "linux", target_os = "windows"))]
fn vst3_inner_extension(triple: &str) -> &'static str {
if triple.contains("linux") {
"so"
} else if triple.contains("windows") {
"vst3"
} else {
"so"
}
}
pub(crate) fn stage_vst2(
root: &Path,
p: &PluginDef,
config: &Config,
staging: &Path,
target: Option<&str>,
) -> Result<std::path::PathBuf, crate::CargoTruceError> {
let _ = config; #[cfg(not(target_os = "macos"))]
let dylib = crate::release_lib_for_target(root, &format!("{}_vst2", p.dylib_stem()), target);
#[cfg(target_os = "macos")]
let dylib = {
let _ = target;
crate::release_bundle_bin(root, &p.dylib_stem(), "_vst2")
};
if !dylib.exists() {
return Err(format!("Missing: {}", dylib.display()).into());
}
#[cfg(target_os = "linux")]
{
let dst = staging.join(format!("{}.so", p.file_stem()));
fs::copy(&dylib, &dst)?;
Ok(dst)
}
#[cfg(target_os = "windows")]
{
let dst = staging.join(format!("{}.dll", p.file_stem()));
fs::copy(&dylib, &dst)?;
Ok(dst)
}
#[cfg(target_os = "macos")]
{
let bundle = staging.join(format!("{}.vst", p.file_stem()));
let macos_dir = bundle.join("Contents/MacOS");
fs::create_dir_all(&macos_dir)?;
let exec_name = p.file_stem();
fs::copy(&dylib, macos_dir.join(&exec_name))?;
let plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>{exec_name}</string>
<key>CFBundleIdentifier</key>
<string>com.truce.{bundle_id}.vst2</string>
<key>CFBundleName</key>
<string>{display_name}</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>"#,
display_name = p.name,
bundle_id = p.bundle_id,
);
fs::write(bundle.join("Contents/Info.plist"), &plist)?;
fs::write(bundle.join("Contents/PkgInfo"), "BNDL????")?;
codesign_bundle(
bundle.to_str().unwrap(),
&crate::application_identity(),
false,
)?;
Ok(bundle)
}
}
#[cfg(target_os = "macos")]
pub(crate) fn stage_au2(root: &Path, p: &PluginDef, config: &Config, staging: &Path) -> Res {
let dylib =
truce_build::target_dir(root).join(format!("release/lib{}_au.dylib", p.dylib_stem()));
if !dylib.exists() {
return Err(format!("Missing: {}", dylib.display()).into());
}
let bundle = staging.join(format!("{}.component", p.file_stem()));
let macos_dir = bundle.join("Contents/MacOS");
fs::create_dir_all(&macos_dir)?;
let exec_name = p.file_stem();
fs::copy(&dylib, macos_dir.join(&exec_name))?;
let plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>{exec_name}</string>
<key>CFBundleIdentifier</key>
<string>{vendor_id}.{bundle_id}.component</string>
<key>CFBundleName</key>
<string>{display_name}</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>AudioComponents</key>
<array>
<dict>
<key>type</key>
<string>{au_type}</string>
<key>subtype</key>
<string>{au_subtype}</string>
<key>manufacturer</key>
<string>{au_mfr}</string>
<key>name</key>
<string>{vendor}: {display_name}</string>
<key>description</key>
<string>{display_name}</string>
<key>version</key>
<integer>65536</integer>
<key>factoryFunction</key>
<string>TruceAUFactory</string>
<key>sandboxSafe</key>
<true/>
<key>tags</key>
<array>
<string>{au_tag}</string>
</array>
</dict>
</array>
</dict>
</plist>"#,
display_name = p.name,
bundle_id = p.bundle_id,
vendor_id = config.vendor.id,
vendor = config.vendor.name,
au_type = p.resolved_au_type(),
au_subtype = p.resolved_fourcc(),
au_mfr = config.vendor.au_manufacturer,
au_tag = p.au_tag,
);
fs::write(bundle.join("Contents/Info.plist"), &plist)?;
codesign_bundle(
bundle.to_str().unwrap(),
&crate::application_identity(),
false,
)?;
Ok(())
}
#[cfg(target_os = "macos")]
pub(crate) fn stage_aax(
root: &Path,
p: &PluginDef,
_config: &Config,
staging: &Path,
_universal_mac: bool,
no_pace_sign: bool,
) -> Res {
let bundle_name = format!("{}.aaxplugin", p.file_stem());
let built = truce_build::target_dir(root)
.join("bundles")
.join(&bundle_name);
if !built.exists() {
return Err(format!(
"AAX bundle missing at {}. Call `emit_aax_bundle` from the package driver before staging.",
built.display()
)
.into());
}
let dst = staging.join(&bundle_name);
let _ = fs::remove_dir_all(&dst);
crate::util::copy_dir_recursive(&built, &dst)?;
if !no_pace_sign {
pace_sign_aax_macos(&dst)?;
}
Ok(())
}
#[cfg(target_os = "macos")]
pub(crate) fn stage_au3(root: &Path, p: &PluginDef, _config: &Config, staging: &Path) -> Res {
let app_name = format!("{}.app", p.au3_app_name());
let built_app = truce_build::target_dir(root)
.join("bundles")
.join(&app_name);
if !built_app.exists() {
return Err(format!(
"AU v3 bundle missing at {}. Run `cargo truce build --au3 -p {}` first.",
built_app.display(),
p.bundle_id,
)
.into());
}
let dst = staging.join(&app_name);
if dst.exists() && fs::remove_dir_all(&dst).is_err() {
let status = Command::new("rm")
.args(["-rf", dst.to_str().unwrap()])
.status();
if dst.exists() {
return Err(format!(
"could not remove stale staging dir {} \
(rm exit: {status:?}). \
This is usually root-owned leftovers from an earlier \
`cargo truce install`. Run:\n \
sudo rm -rf {}",
dst.display(),
dst.display(),
)
.into());
}
}
copy_dir_recursive(&built_app, &dst)?;
Ok(())
}
#[cfg(target_os = "macos")]
pub(crate) fn stage_standalone(root: &Path, p: &PluginDef, config: &Config, staging: &Path) -> Res {
use std::os::unix::fs::PermissionsExt;
let bin_stem = crate::read_standalone_bin_name(&p.crate_name)
.unwrap_or_else(|| format!("{}-standalone", p.crate_name));
let built = truce_build::target_dir(root)
.join("release")
.join(&bin_stem);
if !built.exists() {
return Err(format!(
"Standalone binary missing at {}. \
The build step should have produced it - make sure the \
plugin's Cargo.toml declares a [[bin]] target named '{}'.",
built.display(),
bin_stem,
)
.into());
}
let staged_app = staging.join(format!("{}.app", p.file_stem()));
let _ = fs::remove_dir_all(&staged_app);
let macos_dir = staged_app.join("Contents/MacOS");
fs::create_dir_all(&macos_dir)?;
let exe_dst = macos_dir.join(&bin_stem);
fs::copy(&built, &exe_dst)?;
let mut perms = fs::metadata(&exe_dst)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&exe_dst, perms)?;
let icon_present = if let Some(icon_rel) = &p.macos_icon {
let icon_src = crate::project_root().join(icon_rel);
if !icon_src.exists() {
return Err(format!(
"macos_icon for `{}` points to {} but no file is there.",
p.name,
icon_src.display()
)
.into());
}
let resources_dir = staged_app.join("Contents/Resources");
fs::create_dir_all(&resources_dir)?;
fs::copy(&icon_src, resources_dir.join("icon.icns"))?;
true
} else {
false
};
write_standalone_info_plist(&staged_app, p, &bin_stem, &config.vendor, icon_present)?;
crate::commands::install::presets::emit_standalone_factory(root, p, config, &exe_dst)?;
codesign_bundle(
staged_app.to_str().unwrap(),
&crate::application_identity(),
false,
)?;
Ok(())
}
#[cfg(target_os = "macos")]
pub(crate) fn write_standalone_info_plist(
bundle_root: &Path,
plugin: &PluginDef,
bin_stem: &str,
vendor: &crate::config::VendorConfig,
icon_present: bool,
) -> Res {
let mic_usage = format!(
"{} would like to use the microphone for plugin audio input.",
plugin.name
);
let icon_key = if icon_present {
" <key>CFBundleIconFile</key>\n <string>icon</string>\n"
} else {
""
};
let plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>{name}</string>
<key>CFBundleDisplayName</key>
<string>{name}</string>
<key>CFBundleIdentifier</key>
<string>{vendor_id}.{bundle_id}.standalone</string>
<key>CFBundleExecutable</key>
<string>{exe}</string>
{icon_key} <key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSMicrophoneUsageDescription</key>
<string>{mic_usage}</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.music</string>
</dict>
</plist>
"#,
name = plugin.name,
vendor_id = vendor.id,
bundle_id = plugin.bundle_id,
exe = bin_stem,
);
fs::write(bundle_root.join("Contents/Info.plist"), plist)?;
Ok(())
}
#[cfg(target_os = "macos")]
pub(crate) fn generate_distribution_xml(
plugin_name: &str,
vendor_id: &str,
bundle_id: &str,
formats: &[PkgFormat],
version: &str,
resources: Option<&MacosPackagingConfig>,
scope: PkgScope,
) -> String {
let mut choices_outline = String::new();
let mut choices = String::new();
let mut pkg_refs = String::new();
for fmt in formats {
let id = fmt.pkg_id_suffix();
let pkg_id = format!("{vendor_id}.{bundle_id}.{id}");
let label = fmt.label();
let desc = fmt.choice_description();
let component_file = format!("{plugin_name}-{label}.pkg");
let enabled_attr = "";
let pkg_ref_auth = match (scope, fmt.is_system_only_on_macos()) {
(PkgScope::User | PkgScope::Ask, true) => " auth=\"Root\"",
(PkgScope::User, false) => " auth=\"None\"",
(PkgScope::Ask | PkgScope::System, _) => "",
};
let _ = writeln!(choices_outline, " <line choice=\"{id}\"/>");
let _ = write!(
choices,
r#"
<choice id="{id}" title="{label}" description="{desc}"{enabled_attr}>
<pkg-ref id="{pkg_id}"{pkg_ref_auth}/>
</choice>
"#
);
let _ = writeln!(
pkg_refs,
" <pkg-ref id=\"{pkg_id}\" version=\"{version}\">{component_file}</pkg-ref>"
);
}
let welcome = resources
.and_then(|r| r.welcome_html.as_deref())
.map_or("", |_| " <welcome file=\"welcome.html\"/>\n");
let license = resources
.and_then(|r| r.license_html.as_deref())
.map_or("", |_| " <license file=\"license.html\"/>\n");
let domains = match scope {
PkgScope::User => {
" <domains enable_anywhere=\"false\" enable_currentUserHome=\"true\" \
enable_localSystem=\"false\"/>\n"
}
PkgScope::System => {
" <domains enable_anywhere=\"false\" enable_currentUserHome=\"false\" \
enable_localSystem=\"true\"/>\n"
}
PkgScope::Ask => {
" <domains enable_anywhere=\"false\" enable_currentUserHome=\"true\" \
enable_localSystem=\"true\"/>\n"
}
};
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<installer-gui-script minSpecVersion="2">
<title>{plugin_name}</title>
{welcome}{license}{domains} <options customize="always" require-scripts="false"/>
<choices-outline>
{choices_outline} </choices-outline>
{choices}
{pkg_refs}</installer-gui-script>
"#
)
}
#[cfg(target_os = "macos")]
pub(crate) fn write_format_scripts(
staging: &Path,
fmt: &PkgFormat,
bundle_name: &str,
) -> std::result::Result<PathBuf, crate::CargoTruceError> {
let scripts_dir = staging.join(format!("{}_scripts", fmt.pkg_id_suffix()));
let _ = fs::remove_dir_all(&scripts_dir);
fs::create_dir_all(&scripts_dir)?;
let escaped_bundle = bundle_name.replace('"', "\\\"");
let preinstall = scripts_dir.join("preinstall");
fs::write(
&preinstall,
format!(
"#!/bin/bash\n\
# `cargo truce package` preinstall: remove any prior\n\
# bundle at the destination before shove writes ours.\n\
# `$2` is the resolved install destination (with\n\
# `enable_currentUserHome` redirection applied).\n\
set -u\n\
BUNDLE=\"$2/{escaped_bundle}\"\n\
if [ -e \"$BUNDLE\" ]; then\n \
if rm -rf \"$BUNDLE\" 2>/dev/null; then\n \
echo \"preinstall: removed existing $BUNDLE\"\n \
else\n \
owner=$(stat -f '%Su' \"$BUNDLE\" 2>/dev/null || echo unknown)\n \
echo \"\" >&2\n \
echo \"ERROR: Cannot remove $BUNDLE (owner: $owner).\" >&2\n \
echo \"Either re-run with 'Install for all users of this computer',\" >&2\n \
echo \"or run: sudo rm -rf \\\"$BUNDLE\\\"\" >&2\n \
exit 1\n \
fi\n\
fi\n\
exit 0\n",
),
)?;
Command::new("chmod")
.args(["+x", preinstall.to_str().unwrap()])
.status()?;
if *fmt == PkgFormat::Au2 {
let postinstall = scripts_dir.join("postinstall");
fs::write(
&postinstall,
"#!/bin/bash\n\
killall -9 AudioComponentRegistrar 2>/dev/null || true\n\
rm -rf ~/Library/Caches/AudioUnitCache/ 2>/dev/null || true\n\
rm -f ~/Library/Preferences/com.apple.audio.InfoHelper.plist 2>/dev/null || true\n\
exit 0\n",
)?;
Command::new("chmod")
.args(["+x", postinstall.to_str().unwrap()])
.status()?;
}
Ok(scripts_dir)
}