use std::collections::HashSet;
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::install_scope::{PkgScope, note_once};
use crate::{
Config, PkgFormat, PluginDef, Res, build_aax_template, cargo_build, detect_default_features,
load_config, project_root, read_workspace_version, release_lib_for_target,
resolve_aax_sdk_path, rustup_has_target, tag_info, tag_ok, tag_warn, tmp_aax_template,
tmp_manifests,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TargetArch {
X64,
Arm64,
}
impl TargetArch {
fn host() -> Self {
if cfg!(target_arch = "aarch64") {
TargetArch::Arm64
} else {
TargetArch::X64
}
}
fn triple(self) -> &'static str {
match self {
TargetArch::X64 => "x86_64-pc-windows-msvc",
TargetArch::Arm64 => "aarch64-pc-windows-msvc",
}
}
fn tag(self) -> &'static str {
match self {
TargetArch::X64 => "x64",
TargetArch::Arm64 => "arm64",
}
}
fn vst3_bundle_subdir(self) -> &'static str {
match self {
TargetArch::X64 => "x86_64-win",
TargetArch::Arm64 => "arm64-win",
}
}
fn aax_bundle_subdir(self) -> &'static str {
match self {
TargetArch::X64 => "x64",
TargetArch::Arm64 => "arm64",
}
}
fn iss_check(self) -> &'static str {
match self {
TargetArch::X64 => "not IsArm64",
TargetArch::Arm64 => "IsArm64",
}
}
}
pub(crate) fn cmd_package(
args: &[String],
selection: &crate::commands::package::SuiteSelection,
) -> Res {
let opts = parse_args(args)?;
let config = load_config()?;
let root = project_root();
let version = read_workspace_version(&root).unwrap_or_else(|e| {
eprintln!("WARNING: {e}; defaulting installer version to 0.0.0");
"0.0.0".to_string()
});
let formats = resolve_formats(&config, opts.format_str.as_deref())?;
let plugins = resolve_plugins(&config, opts.plugin_filter.as_deref())?;
let archs = opts.archs();
let universal = archs.len() > 1;
let suites: Vec<crate::config::ResolvedSuite<'_>> = if opts.plugin_filter.is_some() {
if !config.suites.is_empty() {
eprintln!("(-p set; skipping suite installers — they need every member plugin staged)");
}
Vec::new()
} else {
config
.suites
.iter()
.filter(|s| selection.want_suite(&s.name))
.map(|s| s.resolve(&config.plugin))
.collect::<std::result::Result<_, _>>()?
};
let need_staging_for_suites = !selection.want_per_plugin() && !suites.is_empty();
let scope = resolve_pkg_scope(opts.cli_scope, &config)?;
eprintln!("Package scope: {}", scope.label());
if matches!(scope, PkgScope::User) {
for f in &formats {
match f {
PkgFormat::Aax => note_once(
"AAX is system-only; --user package keeps AAX but installs it to \
%COMMONPROGRAMFILES%\\Avid (end user will see one UAC prompt).",
),
PkgFormat::Vst2 => note_once(
"VST2 on Windows is system-only; --user package keeps VST2 but installs \
it to %PROGRAMFILES%\\Steinberg\\VstPlugins (end user will see one UAC prompt).",
),
_ => {}
}
}
}
if universal && formats.iter().any(|f| matches!(f, PkgFormat::Aax)) {
eprintln!(
"NOTE: AAX is host-arch-only ({}); the universal installer won't \
carry an ARM64 AAX bundle. Avid's AAX SDK 2.9 ships x64 libs only, \
and our template build (vcvars64 + MSVC) is x64-only. CLAP/VST2/VST3 \
ship universally; AAX stays single-arch.",
TargetArch::host().tag(),
);
}
if !opts.no_sign && !WindowsSigningEnv::from_env().is_configured() {
eprintln!(
"WARNING: no Windows signing credentials configured. Binaries and \
installer will be unsigned. Pass --no-sign to silence this warning, or \
set TRUCE_AZURE_ACCOUNT (+ TRUCE_AZURE_PROFILE), TRUCE_CERT_SHA1, or \
TRUCE_PFX_PATH in .cargo/config.toml [env]. See \
https://truce.audio/ for the full list."
);
}
build_all_formats(&plugins, &formats, &archs, &root)?;
let dist_dir = truce_build::target_dir(&root).join("dist");
fs::create_dir_all(&dist_dir)?;
for p in &plugins {
eprintln!("\nPackaging: {} ({})", p.name, archs_label(&archs));
let staging = truce_build::target_dir(&root)
.join("package/windows/plugin")
.join(&p.bundle_id);
let _ = fs::remove_dir_all(&staging);
fs::create_dir_all(&staging)?;
let mut all_signable: Vec<PathBuf> = Vec::new();
for &arch in &archs {
let staged = stage_plugin(&root, p, &formats, &staging, arch)?;
all_signable.extend(staged.signable);
}
if !opts.no_sign {
if !opts.no_pace_sign && formats.iter().any(|f| matches!(f, PkgFormat::Aax)) {
let aax_bundle = staging.join(format!("{}.aaxplugin", p.name));
for &arch in &archs {
let inner_wrapper = aax_bundle
.join("Contents")
.join(arch.aax_bundle_subdir())
.join(format!("{}.aaxplugin", p.name));
if inner_wrapper.exists() {
pace_sign_aax(&inner_wrapper)?;
}
}
}
sign_files(&all_signable)?;
}
if opts.no_installer {
eprintln!(
" Skipped installer build (--no-installer). Staging at {}",
staging.display()
);
continue;
}
if !selection.want_per_plugin() {
if need_staging_for_suites {
eprintln!(" (--no-per-plugin) Skipping per-plugin .exe; staging kept for suite.");
}
continue;
}
let iss = render_iss(
&config, p, &formats, &archs, &staging, &version, &dist_dir, scope,
);
let iss_path = staging.join("installer.iss");
fs::write(&iss_path, &iss)?;
run_iscc(&iss_path)?;
let installer = dist_dir.join(format!(
"{}-{}-windows{}.exe",
p.crate_name,
version,
scope.dist_suffix()
));
if !installer.exists() {
return Err(format!(
"ISCC reported success but installer is missing: {}",
installer.display()
)
.into());
}
crate::commands::package::verify::assert_min_size(&installer)?;
if !opts.no_sign {
sign_files(std::slice::from_ref(&installer))?;
}
eprintln!(" Installer: {}", installer.display());
}
if !suites.is_empty() {
eprintln!("\nSuite installers");
let plugins_root = truce_build::target_dir(&root).join("package/windows/plugin");
let suite_root = truce_build::target_dir(&root).join("package/windows/suite");
for suite in &suites {
package_one_suite(
&config,
suite,
&formats,
&archs,
&plugins_root,
&suite_root,
&version,
&dist_dir,
scope,
opts.no_sign,
)?;
}
}
eprintln!("\nDone. Installers in {}", dist_dir.display());
Ok(())
}
fn archs_label(archs: &[TargetArch]) -> String {
archs.iter().map(|a| a.tag()).collect::<Vec<_>>().join("+")
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Default)]
struct Opts {
plugin_filter: Option<String>,
format_str: Option<String>,
no_sign: bool,
no_pace_sign: bool,
no_installer: bool,
host_only: bool,
cli_scope: Option<PkgScope>,
}
impl Opts {
fn archs(&self) -> Vec<TargetArch> {
if self.host_only {
vec![TargetArch::host()]
} else {
vec![TargetArch::X64, TargetArch::Arm64]
}
}
}
fn set_cli_scope(slot: &mut Option<PkgScope>, want: PkgScope) -> Res {
if let Some(prev) = *slot
&& prev != want
{
return Err("--user, --system, and --ask are mutually exclusive".into());
}
*slot = Some(want);
Ok(())
}
fn resolve_pkg_scope(cli: Option<PkgScope>, config: &Config) -> Result<PkgScope, crate::BoxErr> {
if let Some(s) = cli {
return Ok(s);
}
if let Some(ref raw) = config.packaging.preferred_scope {
return raw.parse::<PkgScope>().map_err(Into::into);
}
Ok(PkgScope::os_default())
}
fn parse_args(args: &[String]) -> std::result::Result<Opts, crate::BoxErr> {
let mut opts = Opts::default();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"-p" => {
opts.plugin_filter = Some(crate::util::arg_value(args, &mut i, "-p")?.to_string());
}
"--formats" => {
opts.format_str =
Some(crate::util::arg_value(args, &mut i, "--formats")?.to_string());
}
"--no-sign" => opts.no_sign = true,
"--no-pace-sign" => opts.no_pace_sign = true,
"--no-installer" => opts.no_installer = true,
"--user" => set_cli_scope(&mut opts.cli_scope, PkgScope::User)?,
"--system" => set_cli_scope(&mut opts.cli_scope, PkgScope::System)?,
"--ask" => set_cli_scope(&mut opts.cli_scope, PkgScope::Ask)?,
#[allow(clippy::match_same_arms)]
"--universal" => {}
"--host-only" => opts.host_only = true,
"--no-notarize" => {}
other => return Err(format!("unknown flag: {other}").into()),
}
i += 1;
}
Ok(opts)
}
fn resolve_formats(
config: &Config,
format_str: Option<&str>,
) -> std::result::Result<Vec<PkgFormat>, crate::BoxErr> {
let raw = if let Some(s) = format_str {
PkgFormat::parse_list(s)?
} else if !config.packaging.formats.is_empty() {
PkgFormat::parse_list(&config.packaging.formats.join(","))?
} else {
let available: HashSet<String> = detect_default_features();
let mut fmts = Vec::new();
if available.contains("clap") {
fmts.push(PkgFormat::Clap);
}
if available.contains("vst3") {
fmts.push(PkgFormat::Vst3);
}
if available.contains("vst2") {
fmts.push(PkgFormat::Vst2);
}
if available.contains("lv2") {
fmts.push(PkgFormat::Lv2);
}
if available.contains("aax") {
fmts.push(PkgFormat::Aax);
}
if available.contains("standalone") {
fmts.push(PkgFormat::Standalone);
}
fmts
};
let filtered: Vec<PkgFormat> = raw
.into_iter()
.filter(|f| !matches!(f, PkgFormat::Au2 | PkgFormat::Au3))
.collect();
if filtered.is_empty() {
return Err("no Windows-eligible formats selected (AU is macOS-only)".into());
}
Ok(filtered)
}
fn resolve_plugins<'a>(
config: &'a Config,
filter: Option<&str>,
) -> std::result::Result<Vec<&'a PluginDef>, crate::BoxErr> {
crate::commands::pick_plugins(config, filter)
}
fn build_all_formats(
plugins: &[&PluginDef],
formats: &[PkgFormat],
archs: &[TargetArch],
root: &Path,
) -> Res {
let dt = "";
let has_clap = formats.iter().any(|f| matches!(f, PkgFormat::Clap));
let has_vst3 = formats.iter().any(|f| matches!(f, PkgFormat::Vst3));
let has_vst2 = formats.iter().any(|f| matches!(f, PkgFormat::Vst2));
let has_lv2 = formats.iter().any(|f| matches!(f, PkgFormat::Lv2));
let has_aax = formats.iter().any(|f| matches!(f, PkgFormat::Aax));
let has_standalone = formats.iter().any(|f| matches!(f, PkgFormat::Standalone));
for &arch in archs {
eprintln!("Building for {}", arch.tag());
let triple = arch.triple();
if has_clap {
eprintln!("Building CLAP ({})...", arch.tag());
let mut build_args: Vec<String> = vec!["--target".into(), triple.into()];
for p in plugins {
build_args.push("-p".into());
build_args.push(p.crate_name.clone());
}
build_args.extend_from_slice(&[
"--no-default-features".into(),
"--features".into(),
"clap".into(),
]);
let arg_refs: Vec<&str> = build_args.iter().map(std::string::String::as_str).collect();
cargo_build(&[], &arg_refs, dt)?;
for p in plugins {
let src = release_lib_for_target(root, &p.dylib_stem(), Some(triple));
let saved =
release_lib_for_target(root, &format!("{}_clap", p.dylib_stem()), Some(triple));
if src.exists() {
fs::copy(&src, &saved)?;
}
}
}
if has_vst3 {
eprintln!("Building VST3 ({})...", arch.tag());
let mut build_args: Vec<String> = vec!["--target".into(), triple.into()];
for p in plugins {
build_args.push("-p".into());
build_args.push(p.crate_name.clone());
}
build_args.extend_from_slice(&[
"--no-default-features".into(),
"--features".into(),
"vst3".into(),
]);
let arg_refs: Vec<&str> = build_args.iter().map(std::string::String::as_str).collect();
cargo_build(&[], &arg_refs, dt)?;
for p in plugins {
let src = release_lib_for_target(root, &p.dylib_stem(), Some(triple));
let saved =
release_lib_for_target(root, &format!("{}_vst3", p.dylib_stem()), Some(triple));
if src.exists() {
fs::copy(&src, &saved)?;
}
}
}
if has_vst2 {
eprintln!("Building VST2 ({})...", arch.tag());
let mut build_args: Vec<String> = vec!["--target".into(), triple.into()];
for p in plugins {
build_args.push("-p".into());
build_args.push(p.crate_name.clone());
}
build_args.extend_from_slice(&[
"--no-default-features".into(),
"--features".into(),
"vst2".into(),
]);
let arg_refs: Vec<&str> = build_args.iter().map(std::string::String::as_str).collect();
cargo_build(&[], &arg_refs, dt)?;
for p in plugins {
let src = release_lib_for_target(root, &p.dylib_stem(), Some(triple));
let dst =
release_lib_for_target(root, &format!("{}_vst2", p.dylib_stem()), Some(triple));
fs::copy(&src, &dst)?;
}
}
if has_lv2 {
eprintln!("Building LV2 ({})...", arch.tag());
let mut build_args: Vec<String> = vec!["--target".into(), triple.into()];
for p in plugins {
build_args.push("-p".into());
build_args.push(p.crate_name.clone());
}
build_args.extend_from_slice(&[
"--no-default-features".into(),
"--features".into(),
"lv2".into(),
]);
let arg_refs: Vec<&str> = build_args.iter().map(std::string::String::as_str).collect();
cargo_build(&[], &arg_refs, dt)?;
for p in plugins {
let src = release_lib_for_target(root, &p.dylib_stem(), Some(triple));
let dst =
release_lib_for_target(root, &format!("{}_lv2", p.dylib_stem()), Some(triple));
fs::copy(&src, &dst)?;
}
}
if has_standalone {
eprintln!("Building Standalone ({})...", arch.tag());
let keep_console = crate::read_build_env("TRUCE_STANDALONE_KEEP_CONSOLE")
.is_some_and(|v| v != "0" && !v.is_empty());
let link_args: &[&str] = if keep_console {
&[]
} else {
&[
"-C",
"link-arg=/SUBSYSTEM:WINDOWS",
"-C",
"link-arg=/ENTRY:mainCRTStartup",
]
};
let base_args: Vec<String> = vec![
"--target".into(),
triple.into(),
"--no-default-features".into(),
"--features".into(),
"standalone".into(),
];
let base_refs: Vec<&str> = base_args.iter().map(std::string::String::as_str).collect();
for p in plugins {
let bin_name = crate::read_standalone_bin_name(&p.crate_name)
.unwrap_or_else(|| format!("{}-standalone", p.crate_name));
crate::cargo_rustc_bin(&[], &base_refs, &p.crate_name, &bin_name, link_args)?;
}
}
if has_aax && arch == TargetArch::host() {
eprintln!("Building AAX ({})...", arch.tag());
let mut build_args: Vec<String> = vec!["--target".into(), triple.into()];
for p in plugins {
build_args.push("-p".into());
build_args.push(p.crate_name.clone());
}
build_args.extend_from_slice(&[
"--no-default-features".into(),
"--features".into(),
"aax".into(),
]);
let arg_refs: Vec<&str> = build_args.iter().map(std::string::String::as_str).collect();
cargo_build(&[], &arg_refs, dt)?;
for p in plugins {
let src = release_lib_for_target(root, &p.dylib_stem(), Some(triple));
let dst =
release_lib_for_target(root, &format!("{}_aax", p.dylib_stem()), Some(triple));
fs::copy(&src, &dst)?;
}
}
}
Ok(())
}
struct StagedPlugin {
signable: Vec<PathBuf>,
}
fn stage_plugin(
root: &Path,
p: &PluginDef,
formats: &[PkgFormat],
staging: &Path,
arch: TargetArch,
) -> std::result::Result<StagedPlugin, crate::BoxErr> {
let mut signable = Vec::new();
for fmt in formats {
eprint!(" Staging {} ({})... ", fmt.label(), arch.tag());
match fmt {
PkgFormat::Clap => {
signable.push(stage_clap(root, p, staging, arch)?);
}
PkgFormat::Vst3 => {
signable.push(stage_vst3(root, p, staging, arch)?);
}
PkgFormat::Vst2 => {
signable.push(stage_vst2(root, p, staging, arch)?);
}
PkgFormat::Lv2 => {
signable.push(stage_lv2(root, p, staging, arch)?);
}
PkgFormat::Aax => {
if let Some((wrapper, dylib)) = stage_aax(root, p, staging, arch)? {
signable.push(dylib);
signable.push(wrapper);
} else {
eprintln!("skipped (AAX template is built for host arch only)");
continue;
}
}
PkgFormat::Au2 | PkgFormat::Au3 => {
return Err("AU is macOS-only; should have been filtered".into());
}
PkgFormat::Standalone => {
signable.push(stage_standalone(root, p, staging, arch)?);
}
}
eprintln!("ok");
}
Ok(StagedPlugin { signable })
}
fn stage_clap(
root: &Path,
p: &PluginDef,
staging: &Path,
arch: TargetArch,
) -> std::result::Result<PathBuf, crate::BoxErr> {
let dll = release_lib_for_target(
root,
&format!("{}_clap", p.dylib_stem()),
Some(arch.triple()),
);
if !dll.exists() {
return Err(format!("Missing: {}", dll.display()).into());
}
let dst_dir = staging.join("clap").join(arch.tag());
fs::create_dir_all(&dst_dir)?;
let dst = dst_dir.join(format!("{}.clap", p.name));
fs::copy(&dll, &dst)?;
Ok(dst)
}
fn stage_standalone(
root: &Path,
p: &PluginDef,
staging: &Path,
arch: TargetArch,
) -> std::result::Result<PathBuf, crate::BoxErr> {
let bin_stem = crate::read_standalone_bin_name(&p.crate_name)
.unwrap_or_else(|| format!("{}-standalone", p.crate_name));
let exe_name = format!("{bin_stem}.exe");
let built = truce_build::target_dir(root)
.join(arch.triple())
.join("release")
.join(&exe_name);
if !built.exists() {
return Err(format!(
"Standalone build produced no binary at {}. \
Make sure the plugin's Cargo.toml declares a [[bin]] target named '{bin_stem}'.",
built.display()
)
.into());
}
let dst_dir = staging.join("standalone").join(arch.tag());
fs::create_dir_all(&dst_dir)?;
let dst = dst_dir.join(&exe_name);
fs::copy(&built, &dst)?;
crate::windows_manifest::embed_dpi_manifest(&dst)?;
if let Some(icon) = plugin_windows_icon(p, root) {
crate::windows_manifest::embed_icon(&dst, &icon)?;
}
Ok(dst)
}
fn plugin_windows_icon(p: &PluginDef, root: &Path) -> Option<PathBuf> {
p.windows_icon
.as_ref()
.map(|s| root.join(s))
.filter(|p| p.exists())
}
fn stage_vst3(
root: &Path,
p: &PluginDef,
staging: &Path,
arch: TargetArch,
) -> std::result::Result<PathBuf, crate::BoxErr> {
let dll = release_lib_for_target(
root,
&format!("{}_vst3", p.dylib_stem()),
Some(arch.triple()),
);
if !dll.exists() {
return Err(format!("Missing: {}", dll.display()).into());
}
let bundle_root = staging.join("vst3");
let bundle = bundle_root.join(format!("{}.vst3", p.name));
let arch_dir = bundle.join("Contents").join(arch.vst3_bundle_subdir());
fs::create_dir_all(&arch_dir)?;
let inner = arch_dir.join(format!("{}.vst3", p.name));
fs::copy(&dll, &inner)?;
Ok(inner)
}
fn stage_vst2(
root: &Path,
p: &PluginDef,
staging: &Path,
arch: TargetArch,
) -> std::result::Result<PathBuf, crate::BoxErr> {
let dll = release_lib_for_target(
root,
&format!("{}_vst2", p.dylib_stem()),
Some(arch.triple()),
);
if !dll.exists() {
return Err(format!("Missing: {}", dll.display()).into());
}
let dst_dir = staging.join("vst2").join(arch.tag());
fs::create_dir_all(&dst_dir)?;
let dst = dst_dir.join(format!("{}.dll", p.name));
fs::copy(&dll, &dst)?;
Ok(dst)
}
fn stage_lv2(
root: &Path,
p: &PluginDef,
staging: &Path,
arch: TargetArch,
) -> std::result::Result<PathBuf, crate::BoxErr> {
use crate::commands::package::stage::lv2_slug;
let dll = release_lib_for_target(
root,
&format!("{}_lv2", p.dylib_stem()),
Some(arch.triple()),
);
if !dll.exists() {
return Err(format!("Missing: {}", dll.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("lv2")
.join(arch.tag())
.join(format!("{slug}.lv2"));
let _ = fs::remove_dir_all(&bundle);
fs::create_dir_all(&bundle)?;
let dst_dll = bundle.join(format!("{slug}.dll"));
fs::copy(&dll, &dst_dll)?;
fs::copy(&manifest_ttl, bundle.join("manifest.ttl"))?;
fs::copy(&plugin_ttl, bundle.join("plugin.ttl"))?;
Ok(dst_dll)
}
fn stage_aax(
root: &Path,
p: &PluginDef,
staging: &Path,
arch: TargetArch,
) -> std::result::Result<Option<(PathBuf, PathBuf)>, crate::BoxErr> {
if arch != TargetArch::host() {
return Ok(None);
}
let template = tmp_aax_template().join("build/TruceAAXTemplate.aaxplugin");
if !template.exists() {
if let Some(sdk_path) = resolve_aax_sdk_path() {
eprintln!("AAX: building template with SDK at {}", sdk_path.display());
build_aax_template(root, &sdk_path, false)?;
} else {
return Err(
"AAX SDK not configured. Set AAX_SDK_PATH in .cargo/config.toml [env] \
(or as a shell env var)."
.into(),
);
}
}
if !template.exists() {
return Err("AAX template build succeeded but binary not found".into());
}
let dylib = release_lib_for_target(
root,
&format!("{}_aax", p.dylib_stem()),
Some(arch.triple()),
);
if !dylib.exists() {
return Err(format!(
"Missing AAX Rust cdylib for {}: {}",
arch.tag(),
dylib.display()
)
.into());
}
let bundle_root = staging.join("aax");
let bundle = bundle_root.join(format!("{}.aaxplugin", p.name));
let contents = bundle.join("Contents");
let arch_dir = contents.join(arch.aax_bundle_subdir());
let resources_dir = contents.join("Resources");
fs::create_dir_all(&arch_dir)?;
fs::create_dir_all(&resources_dir)?;
let wrapper = arch_dir.join(format!("{}.aaxplugin", p.name));
let resource_dll = resources_dir.join(format!("{}_aax_{}.dll", p.dylib_stem(), arch.tag()));
fs::copy(&template, &wrapper)?;
fs::copy(&dylib, &resource_dll)?;
Ok(Some((wrapper, resource_dll)))
}
struct WindowsSigningEnv {
azure_account: Option<String>,
azure_profile: Option<String>,
azure_dlib: Option<String>,
cert_sha1: Option<String>,
cert_store: Option<String>,
pfx_path: Option<String>,
pfx_password: Option<String>,
timestamp_url: String,
}
impl WindowsSigningEnv {
fn from_env() -> Self {
Self {
azure_account: crate::read_build_env("TRUCE_AZURE_ACCOUNT"),
azure_profile: crate::read_build_env("TRUCE_AZURE_PROFILE"),
azure_dlib: crate::read_build_env("TRUCE_AZURE_DLIB"),
cert_sha1: crate::read_build_env("TRUCE_CERT_SHA1"),
cert_store: crate::read_build_env("TRUCE_CERT_STORE"),
pfx_path: crate::read_build_env("TRUCE_PFX_PATH"),
pfx_password: crate::read_build_env("TRUCE_PFX_PASSWORD"),
timestamp_url: crate::read_build_env("TRUCE_TIMESTAMP_URL")
.unwrap_or_else(|| "http://timestamp.digicert.com".to_string()),
}
}
fn is_configured(&self) -> bool {
self.azure_account.is_some() || self.cert_sha1.is_some() || self.pfx_path.is_some()
}
}
fn sign_files(files: &[PathBuf]) -> Res {
if files.is_empty() {
return Ok(());
}
let env = WindowsSigningEnv::from_env();
if !env.is_configured() {
return Ok(());
}
let signtool = locate_signtool()
.ok_or("signtool.exe not found on PATH. Install the Windows 10 SDK or Windows 11 SDK.")?;
let mut args: Vec<String> = vec![
"sign".into(),
"/fd".into(),
"SHA256".into(),
"/tr".into(),
env.timestamp_url.clone(),
"/td".into(),
"SHA256".into(),
];
if let (Some(account), Some(profile)) = (&env.azure_account, &env.azure_profile) {
let dlib = env.azure_dlib.clone().unwrap_or_else(default_azure_dlib);
let metadata_path = tmp_manifests().join("truce_azure_signing_metadata.json");
let metadata = format!(
r#"{{
"Endpoint": "https://eus.codesigning.azure.net/",
"CodeSigningAccountName": "{account}",
"CertificateProfileName": "{profile}"
}}"#,
);
fs::write(&metadata_path, metadata)?;
args.extend_from_slice(&[
"/dlib".into(),
dlib,
"/dmdf".into(),
metadata_path.display().to_string(),
]);
} else if let Some(sha1) = &env.cert_sha1 {
args.extend_from_slice(&["/sha1".into(), sha1.clone()]);
if let Some(store) = &env.cert_store {
args.extend_from_slice(&["/s".into(), store.clone()]);
}
} else if let Some(pfx) = &env.pfx_path {
args.extend_from_slice(&["/f".into(), pfx.clone()]);
if let Some(pw) = &env.pfx_password {
args.extend_from_slice(&["/p".into(), pw.clone()]);
}
}
for f in files {
args.push(f.display().to_string());
}
eprintln!(" signtool: signing {} file(s)", files.len());
let status = Command::new(&signtool).args(&args).status()?;
if !status.success() {
return Err("signtool failed".into());
}
Ok(())
}
fn default_azure_dlib() -> String {
r"C:\Program Files\Microsoft Trusted Signing Client\bin\x64\Azure.CodeSigning.Dlib.dll"
.to_string()
}
fn locate_signtool() -> Option<PathBuf> {
if let Ok(p) = which("signtool.exe") {
return Some(p);
}
let candidates = [r"C:\Program Files (x86)\Windows Kits\10\bin\x64\signtool.exe"];
for c in &candidates {
let p = PathBuf::from(c);
if p.exists() {
return Some(p);
}
}
let sdk_bin = PathBuf::from(r"C:\Program Files (x86)\Windows Kits\10\bin");
fs::read_dir(&sdk_bin)
.ok()?
.flatten()
.map(|e| e.path().join(r"x64\signtool.exe"))
.filter(|p| p.exists())
.max()
}
pub(crate) fn locate_iscc() -> Option<PathBuf> {
if let Ok(p) = which("ISCC.exe") {
return Some(p);
}
for c in [
r"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
r"C:\Program Files\Inno Setup 6\ISCC.exe",
] {
let p = PathBuf::from(c);
if p.exists() {
return Some(p);
}
}
None
}
pub(crate) fn locate_wraptool() -> Option<PathBuf> {
if let Ok(p) = which("wraptool.exe") {
return Some(p);
}
None
}
fn which(name: &str) -> Result<PathBuf, std::io::Error> {
let path = std::env::var_os("PATH")
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "PATH not set"))?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(name);
if candidate.is_file() {
return Ok(candidate);
}
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
name.to_string(),
))
}
fn pace_sign_aax(bundle: &Path) -> Res {
let Some(wraptool) = locate_wraptool() else {
eprintln!(
" wraptool.exe not found — AAX bundle is unsigned for PACE. \
Pro Tools Developer will still load it; release builds need PACE."
);
return Ok(());
};
let Ok(pace_account) = std::env::var("PACE_ACCOUNT") else {
eprintln!(
" PACE_ACCOUNT env var not set — skipping PACE signing. \
Pro Tools Developer will still load the bundle."
);
return Ok(());
};
let Ok(pace_signid) = std::env::var("PACE_SIGN_ID") else {
eprintln!(" PACE_SIGN_ID env var not set — skipping PACE signing.");
return Ok(());
};
eprintln!(" wraptool: PACE-signing {}", bundle.display());
let status = Command::new(&wraptool)
.args([
"sign",
"--account",
&pace_account,
"--signid",
&pace_signid,
"--allowsigningservice",
"--in",
bundle.to_str().unwrap(),
"--out",
bundle.to_str().unwrap(),
])
.status()?;
if !status.success() {
return Err("wraptool failed".into());
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn render_iss(
config: &Config,
p: &PluginDef,
formats: &[PkgFormat],
archs: &[TargetArch],
staging: &Path,
version: &str,
dist_dir: &Path,
scope: PkgScope,
) -> String {
let publisher = config
.windows
.packaging
.publisher
.as_deref()
.unwrap_or(&config.vendor.name);
let publisher_url = config
.windows
.packaging
.publisher_url
.clone()
.or_else(|| config.vendor.url.clone())
.unwrap_or_default();
let app_id = config
.windows
.packaging
.app_id
.clone()
.unwrap_or_else(|| format!("{}.{}", config.vendor.id, p.bundle_id));
let root = project_root();
let installer_icon = config
.windows
.packaging
.installer_icon
.as_ref()
.map(|s| root.join(s))
.filter(|p| p.exists());
let welcome_bmp = config
.windows
.packaging
.welcome_bmp
.as_ref()
.map(|s| root.join(s))
.filter(|p| p.exists());
let license_rtf = config
.windows
.packaging
.license_rtf
.as_ref()
.map(|s| root.join(s))
.filter(|p| p.exists());
let universal = archs.len() > 1;
let mut setup = String::new();
setup.push_str("[Setup]\r\n");
let _ = write!(setup, "AppId={{{{{}}}}}\r\n", iss_escape(&app_id));
let _ = write!(setup, "AppName={}\r\n", iss_escape(&p.name));
let _ = write!(setup, "AppVersion={}\r\n", iss_escape(version));
let _ = write!(setup, "AppPublisher={}\r\n", iss_escape(publisher));
if !publisher_url.is_empty() {
let _ = write!(setup, "AppPublisherURL={}\r\n", iss_escape(&publisher_url));
}
let pf_const = scoped_pf(scope);
let _ = write!(
setup,
"DefaultDirName={}\\{}\\{}\r\n",
pf_const,
iss_escape(publisher),
iss_escape(&p.name),
);
setup.push_str("DisableDirPage=yes\r\n");
let _ = write!(setup, "OutputDir={}\r\n", iss_escape_path(dist_dir));
let _ = write!(
setup,
"OutputBaseFilename={}-{}-windows\r\n",
iss_escape(&p.crate_name),
iss_escape(version),
);
setup.push_str("Compression=lzma2\r\n");
setup.push_str("SolidCompression=yes\r\n");
if universal {
setup.push_str("ArchitecturesInstallIn64BitMode=x64compatible\r\n");
setup.push_str("ArchitecturesAllowed=x64compatible\r\n");
} else {
setup.push_str("ArchitecturesInstallIn64BitMode=x64compatible\r\n");
setup.push_str("ArchitecturesAllowed=x64compatible and not arm64\r\n");
}
write_privileges_required(&mut setup, scope, formats);
setup.push_str("WizardStyle=modern\r\n");
setup.push_str("UninstallDisplayName=");
setup.push_str(&iss_escape(&p.name));
setup.push_str("\r\n");
if let Some(icon) = &installer_icon {
let _ = write!(setup, "SetupIconFile={}\r\n", iss_escape_path(icon));
}
if let Some(bmp) = &welcome_bmp {
let _ = write!(setup, "WizardImageFile={}\r\n", iss_escape_path(bmp));
}
if let Some(rtf) = &license_rtf {
let _ = write!(setup, "LicenseFile={}\r\n", iss_escape_path(rtf));
}
setup.push_str("\r\n");
setup.push_str("[Types]\r\n");
setup.push_str("Name: \"full\"; Description: \"Full installation\"\r\n");
setup.push_str(
"Name: \"custom\"; Description: \"Custom installation\"; Flags: iscustom\r\n\r\n",
);
setup.push_str("[Components]\r\n");
for fmt in formats {
let (name, desc, types) = iss_component_spec(fmt);
let size = component_install_size(fmt, p, staging, archs, universal, scope);
let _ = write!(
setup,
"Name: \"{name}\"; Description: \"{desc}\"; Types: {types}; ExtraDiskSpaceRequired: {size}\r\n"
);
}
setup.push_str("\r\n");
setup.push_str("[Files]\r\n");
for fmt in formats {
for &arch in archs {
let block = iss_files_block(
fmt, p, staging, arch, universal, scope, None,
);
setup.push_str(&block);
}
}
setup.push_str("\r\n");
if formats.contains(&PkgFormat::Standalone) {
let bin_stem = crate::read_standalone_bin_name(&p.crate_name)
.unwrap_or_else(|| format!("{}-standalone", p.crate_name));
write_icons_section(
&mut setup,
&iss_escape(&p.name),
&bin_stem,
"standalone",
scope,
);
}
setup.push_str("[UninstallDelete]\r\n");
for fmt in formats {
for line in iss_uninstall_lines(fmt, &p.name, scope, None) {
setup.push_str(&line);
setup.push_str("\r\n");
}
}
setup
}
fn write_privileges_required(setup: &mut String, scope: PkgScope, formats: &[PkgFormat]) {
let has_system_only_format = formats
.iter()
.any(|f| matches!(f, PkgFormat::Aax | PkgFormat::Vst2));
match scope {
PkgScope::User if has_system_only_format => {
setup.push_str("PrivilegesRequired=admin\r\n");
setup.push_str("UsedUserAreasWarning=no\r\n");
}
PkgScope::User => setup.push_str("PrivilegesRequired=lowest\r\n"),
PkgScope::System => setup.push_str("PrivilegesRequired=admin\r\n"),
PkgScope::Ask => {
setup.push_str("PrivilegesRequired=admin\r\n");
setup.push_str("PrivilegesRequiredOverridesAllowed=commandline dialog\r\n");
setup.push_str("UsedUserAreasWarning=no\r\n");
}
}
}
fn write_icons_section(
setup: &mut String,
plugin_name: &str,
bin_stem: &str,
component: &str,
scope: PkgScope,
) {
let programs = scoped_programs(scope);
setup.push_str("[Icons]\r\n");
let _ = write!(
setup,
"Name: \"{programs}\\{plugin_name}\"; Filename: \"{{app}}\\{bin_stem}.exe\"; \
Components: {component}\r\n\r\n"
);
}
#[allow(clippy::too_many_arguments)]
fn package_one_suite(
config: &Config,
suite: &crate::config::ResolvedSuite<'_>,
formats: &[PkgFormat],
archs: &[TargetArch],
plugins_root: &Path,
suite_root: &Path,
workspace_version: &str,
dist_dir: &Path,
scope: PkgScope,
no_sign: bool,
) -> Res {
let suite_name = &suite.def.name;
eprintln!(
"\n → {} ({} plugins, {})",
suite_name,
suite.plugins.len(),
archs_label(archs)
);
for plugin in &suite.plugins {
let plugin_staging = plugins_root.join(&plugin.bundle_id);
if !plugin_staging.exists() {
return Err(format!(
"suite '{}': missing staging for {} at {}. \
Run `cargo truce package` without --no-per-plugin first, \
or omit --no-per-plugin so the suite flow stages it.",
suite_name,
plugin.name,
plugin_staging.display()
)
.into());
}
}
let suite_version = suite.def.version.as_deref().unwrap_or(workspace_version);
let suite_staging = suite_root.join(&suite.def.bundle_id);
let _ = fs::remove_dir_all(&suite_staging);
fs::create_dir_all(&suite_staging)?;
let iss = render_suite_iss(
config,
suite,
formats,
archs,
plugins_root,
suite_version,
dist_dir,
scope,
);
let iss_path = suite_staging.join("installer.iss");
fs::write(&iss_path, &iss)?;
run_iscc(&iss_path)?;
let installer = dist_dir.join(format!(
"{}-{}-windows{}.exe",
suite.def.bundle_id,
suite_version,
scope.dist_suffix()
));
if !installer.exists() {
return Err(format!(
"ISCC reported success but suite installer is missing: {}",
installer.display()
)
.into());
}
crate::commands::package::verify::assert_min_size(&installer)?;
if !no_sign {
sign_files(std::slice::from_ref(&installer))?;
}
eprintln!(" Suite installer: {}", installer.display());
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn render_suite_iss(
config: &Config,
suite: &crate::config::ResolvedSuite<'_>,
formats: &[PkgFormat],
archs: &[TargetArch],
staging_root: &Path,
version: &str,
dist_dir: &Path,
scope: PkgScope,
) -> String {
let universal = archs.len() > 1;
let mut setup = String::new();
write_suite_setup_section(&mut setup, config, suite, formats, version, dist_dir, scope);
setup.push_str("\r\n");
setup.push_str("[Types]\r\n");
setup.push_str("Name: \"full\"; Description: \"Full installation\"\r\n");
setup.push_str(
"Name: \"custom\"; Description: \"Custom installation\"; Flags: iscustom\r\n\r\n",
);
write_suite_components_section(&mut setup, suite, formats, archs, staging_root, scope);
setup.push_str("\r\n");
setup.push_str("[Files]\r\n");
for plugin in &suite.plugins {
let prefix = sanitize_component_name(&plugin.name);
let plugin_staging = staging_root.join(&plugin.bundle_id);
for fmt in formats {
for &arch in archs {
let block = iss_files_block(
fmt,
plugin,
&plugin_staging,
arch,
universal,
scope,
Some(&prefix),
);
setup.push_str(&block);
}
}
}
setup.push_str("\r\n");
if formats.contains(&PkgFormat::Standalone) {
let prefixed_components: Vec<(String, String, String)> = suite
.plugins
.iter()
.map(|plugin| {
let prefix = sanitize_component_name(&plugin.name);
let bin_stem = crate::read_standalone_bin_name(&plugin.crate_name)
.unwrap_or_else(|| format!("{}-standalone", plugin.crate_name));
(
iss_escape(&plugin.name),
bin_stem,
format!("{prefix}\\standalone"),
)
})
.collect();
let programs = scoped_programs(scope);
setup.push_str("[Icons]\r\n");
for (plugin_name, bin_stem, component) in &prefixed_components {
let _ = write!(
setup,
"Name: \"{programs}\\{plugin_name}\"; Filename: \"{{app}}\\{bin_stem}.exe\"; \
Components: {component}\r\n"
);
}
setup.push_str("\r\n");
}
setup.push_str("[UninstallDelete]\r\n");
for plugin in &suite.plugins {
let prefix = sanitize_component_name(&plugin.name);
for fmt in formats {
for line in iss_uninstall_lines(fmt, &plugin.name, scope, Some(&prefix)) {
setup.push_str(&line);
setup.push_str("\r\n");
}
}
}
setup
}
fn write_suite_setup_section(
setup: &mut String,
config: &Config,
suite: &crate::config::ResolvedSuite<'_>,
formats: &[PkgFormat],
version: &str,
dist_dir: &Path,
scope: PkgScope,
) {
let publisher = config
.windows
.packaging
.publisher
.as_deref()
.unwrap_or(&config.vendor.name);
let publisher_url = config
.windows
.packaging
.publisher_url
.clone()
.or_else(|| config.vendor.url.clone())
.unwrap_or_default();
let suite_app_id = format!("{}.{}", config.vendor.id, suite.def.bundle_id);
let root = project_root();
let installer_icon = config
.windows
.packaging
.installer_icon
.as_ref()
.map(|s| root.join(s))
.filter(|p| p.exists());
let welcome_bmp = config
.windows
.packaging
.welcome_bmp
.as_ref()
.map(|s| root.join(s))
.filter(|p| p.exists());
let license_rtf = config
.windows
.packaging
.license_rtf
.as_ref()
.map(|s| root.join(s))
.filter(|p| p.exists());
setup.push_str("[Setup]\r\n");
let _ = write!(setup, "AppId={{{{{}}}}}\r\n", iss_escape(&suite_app_id));
let _ = write!(setup, "AppName={}\r\n", iss_escape(&suite.def.name));
let _ = write!(setup, "AppVersion={}\r\n", iss_escape(version));
let _ = write!(setup, "AppPublisher={}\r\n", iss_escape(publisher));
if !publisher_url.is_empty() {
let _ = write!(setup, "AppPublisherURL={}\r\n", iss_escape(&publisher_url));
}
let pf_const = scoped_pf(scope);
let _ = write!(
setup,
"DefaultDirName={}\\{}\\{}\r\n",
pf_const,
iss_escape(publisher),
iss_escape(&suite.def.name),
);
setup.push_str("DisableDirPage=yes\r\n");
let _ = write!(setup, "OutputDir={}\r\n", iss_escape_path(dist_dir));
let _ = write!(
setup,
"OutputBaseFilename={}-{}-windows\r\n",
iss_escape(&suite.def.bundle_id),
iss_escape(version),
);
setup.push_str("Compression=lzma2\r\n");
setup.push_str("SolidCompression=yes\r\n");
setup.push_str("ArchitecturesInstallIn64BitMode=x64compatible\r\n");
setup.push_str("ArchitecturesAllowed=x64compatible\r\n");
write_privileges_required(setup, scope, formats);
setup.push_str("WizardStyle=modern\r\n");
let _ = write!(
setup,
"UninstallDisplayName={}\r\n",
iss_escape(&suite.def.name)
);
if let Some(icon) = &installer_icon {
let _ = write!(setup, "SetupIconFile={}\r\n", iss_escape_path(icon));
}
if let Some(bmp) = &welcome_bmp {
let _ = write!(setup, "WizardImageFile={}\r\n", iss_escape_path(bmp));
}
if let Some(rtf) = &license_rtf {
let _ = write!(setup, "LicenseFile={}\r\n", iss_escape_path(rtf));
}
}
fn write_suite_components_section(
setup: &mut String,
suite: &crate::config::ResolvedSuite<'_>,
formats: &[PkgFormat],
archs: &[TargetArch],
staging_root: &Path,
scope: PkgScope,
) {
let universal = archs.len() > 1;
setup.push_str("[Components]\r\n");
for plugin in &suite.plugins {
let prefix = sanitize_component_name(&plugin.name);
let _ = write!(
setup,
"Name: \"{prefix}\"; Description: \"{}\"; Types: full custom\r\n",
iss_escape(&plugin.name)
);
let plugin_staging = staging_root.join(&plugin.bundle_id);
for fmt in formats {
let (suffix, desc, types) = iss_component_spec(fmt);
let size =
component_install_size(fmt, plugin, &plugin_staging, archs, universal, scope);
let _ = write!(
setup,
"Name: \"{prefix}\\{suffix}\"; Description: \"{desc}\"; Types: {types}; ExtraDiskSpaceRequired: {size}\r\n"
);
}
}
}
fn sanitize_component_name(s: &str) -> String {
let mut out: String = s
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
if out.is_empty() {
out.push('_');
}
out
}
fn component_install_size(
fmt: &PkgFormat,
p: &PluginDef,
staging: &Path,
archs: &[TargetArch],
universal: bool,
scope: PkgScope,
) -> u64 {
if !files_all_check_gated(fmt, universal, scope) {
return 0;
}
match fmt {
PkgFormat::Clap => archs
.iter()
.filter_map(|a| {
let f = staging
.join("clap")
.join(a.tag())
.join(format!("{}.clap", p.name));
fs::metadata(&f).ok().map(|m| m.len())
})
.max()
.unwrap_or(0),
PkgFormat::Vst2 => archs
.iter()
.filter_map(|a| {
let f = staging
.join("vst2")
.join(a.tag())
.join(format!("{}.dll", p.name));
fs::metadata(&f).ok().map(|m| m.len())
})
.max()
.unwrap_or(0),
PkgFormat::Lv2 => {
use crate::commands::package::stage::lv2_slug;
let slug = lv2_slug(&p.name);
archs
.iter()
.map(|a| {
dir_size_recursive(
&staging
.join("lv2")
.join(a.tag())
.join(format!("{slug}.lv2")),
)
})
.max()
.unwrap_or(0)
}
PkgFormat::Vst3 => {
let contents = staging
.join("vst3")
.join(format!("{}.vst3", p.name))
.join("Contents");
archs
.iter()
.map(|a| dir_size_recursive(&contents.join(a.vst3_bundle_subdir())))
.max()
.unwrap_or(0)
}
PkgFormat::Aax => {
dir_size_recursive(&staging.join("aax").join(format!("{}.aaxplugin", p.name)))
}
PkgFormat::Standalone => {
let bin_stem = crate::read_standalone_bin_name(&p.crate_name)
.unwrap_or_else(|| format!("{}-standalone", p.crate_name));
archs
.iter()
.filter_map(|a| {
let f = staging
.join("standalone")
.join(a.tag())
.join(format!("{bin_stem}.exe"));
fs::metadata(&f).ok().map(|m| m.len())
})
.max()
.unwrap_or(0)
}
PkgFormat::Au2 | PkgFormat::Au3 => 0,
}
}
#[allow(clippy::match_same_arms)]
fn files_all_check_gated(fmt: &PkgFormat, universal: bool, scope: PkgScope) -> bool {
match fmt {
PkgFormat::Clap => universal,
PkgFormat::Vst3 => universal,
PkgFormat::Vst2 => universal || matches!(scope, PkgScope::Ask),
PkgFormat::Lv2 => universal,
PkgFormat::Aax => matches!(scope, PkgScope::Ask),
PkgFormat::Standalone => universal,
PkgFormat::Au2 | PkgFormat::Au3 => false,
}
}
fn dir_size_recursive(path: &Path) -> u64 {
let Ok(entries) = fs::read_dir(path) else {
return 0;
};
let mut total = 0u64;
for entry in entries.flatten() {
let p = entry.path();
match fs::metadata(&p) {
Ok(md) if md.is_dir() => total += dir_size_recursive(&p),
Ok(md) => total += md.len(),
Err(_) => {}
}
}
total
}
fn iss_component_spec(fmt: &PkgFormat) -> (&'static str, &'static str, &'static str) {
match fmt {
PkgFormat::Clap => ("clap", "CLAP", "full"),
PkgFormat::Vst3 => ("vst3", "VST3", "full"),
PkgFormat::Vst2 => ("vst2", "VST2 (legacy)", "full"),
PkgFormat::Lv2 => ("lv2", "LV2", "full"),
PkgFormat::Aax => ("aax", "AAX", "full"),
PkgFormat::Standalone => ("standalone", "Standalone app", "full"),
PkgFormat::Au2 | PkgFormat::Au3 => unreachable!("AU is filtered out on Windows"),
}
}
fn iss_files_block(
fmt: &PkgFormat,
p: &PluginDef,
staging: &Path,
arch: TargetArch,
universal: bool,
scope: PkgScope,
component_prefix: Option<&str>,
) -> String {
let arch_check = if universal {
Some(arch.iss_check())
} else {
None
};
let comp = |suffix: &str| -> String {
match component_prefix {
Some(prefix) => format!("{prefix}\\{suffix}"),
None => suffix.to_string(),
}
};
match fmt {
PkgFormat::Clap => {
let src = staging
.join("clap")
.join(arch.tag())
.join(format!("{}.clap", p.name));
let src_quoted = iss_escape_path(&src);
let dest = format!("{}\\CLAP", scoped_cf(scope));
iss_dual_dest(
&src_quoted,
&dest,
&comp("clap"),
arch_check,
false,
)
}
PkgFormat::Vst3 => {
let src_dir = staging
.join("vst3")
.join(format!("{}.vst3", p.name))
.join("Contents")
.join(arch.vst3_bundle_subdir());
let src_glob = src_dir.join("*");
let src_quoted = iss_escape_path(&src_glob);
let name = iss_escape(&p.name);
let subdir = arch.vst3_bundle_subdir();
let dest = format!(
"{}\\VST3\\{name}.vst3\\Contents\\{subdir}",
scoped_cf(scope)
);
iss_dual_dest(
&src_quoted,
&dest,
&comp("vst3"),
arch_check,
true,
)
}
PkgFormat::Vst2 => {
let src = staging
.join("vst2")
.join(arch.tag())
.join(format!("{}.dll", p.name));
let src_quoted = iss_escape_path(&src);
iss_admin_only(
scope,
&src_quoted,
"{commonpf}\\Steinberg\\VstPlugins",
&comp("vst2"),
arch_check,
false,
)
}
PkgFormat::Lv2 => {
use crate::commands::package::stage::lv2_slug;
let slug = lv2_slug(&p.name);
let src_dir = staging
.join("lv2")
.join(arch.tag())
.join(format!("{slug}.lv2"));
let src_glob = src_dir.join("*");
let src_quoted = iss_escape_path(&src_glob);
let lv2_root = match scope {
PkgScope::System => "{commoncf}\\LV2",
PkgScope::User => "{userappdata}\\LV2",
PkgScope::Ask => "{autoappdata}\\LV2",
};
let dest = format!("{lv2_root}\\{slug}.lv2");
iss_dual_dest(
&src_quoted,
&dest,
&comp("lv2"),
arch_check,
true,
)
}
PkgFormat::Aax => {
let src_arch_dir = staging
.join("aax")
.join(format!("{}.aaxplugin", p.name))
.join("Contents")
.join(arch.aax_bundle_subdir());
if !src_arch_dir.exists() {
return String::new();
}
let src_arch_glob = src_arch_dir.join("*");
let resource_dll = staging
.join("aax")
.join(format!("{}.aaxplugin", p.name))
.join("Contents")
.join("Resources")
.join(format!("{}_aax_{}.dll", p.dylib_stem(), arch.tag()));
let name = iss_escape(&p.name);
let subdir = arch.aax_bundle_subdir();
let bundle_root = format!("{{commoncf}}\\Avid\\Audio\\Plug-Ins\\{name}.aaxplugin");
let mut out = String::new();
out.push_str(&iss_admin_only(
scope,
&iss_escape_path(&src_arch_glob),
&format!("{bundle_root}\\Contents\\{subdir}"),
&comp("aax"),
None,
true,
));
out.push_str(&iss_admin_only(
scope,
&iss_escape_path(&resource_dll),
&format!("{bundle_root}\\Contents\\Resources"),
&comp("aax"),
None,
false,
));
out
}
PkgFormat::Standalone => {
let bin_stem = crate::read_standalone_bin_name(&p.crate_name)
.unwrap_or_else(|| format!("{}-standalone", p.crate_name));
let src = staging
.join("standalone")
.join(arch.tag())
.join(format!("{bin_stem}.exe"));
let src_quoted = iss_escape_path(&src);
iss_dual_dest(
&src_quoted,
"{app}",
&comp("standalone"),
arch_check,
false,
)
}
PkgFormat::Au2 | PkgFormat::Au3 => unreachable!(),
}
}
fn scoped_cf(scope: PkgScope) -> &'static str {
match scope {
PkgScope::System => "{commoncf}",
PkgScope::User => "{usercf}",
PkgScope::Ask => "{autocf}",
}
}
fn scoped_pf(scope: PkgScope) -> &'static str {
match scope {
PkgScope::System => "{commonpf}",
PkgScope::User => "{userpf}",
PkgScope::Ask => "{autopf}",
}
}
fn scoped_programs(scope: PkgScope) -> &'static str {
match scope {
PkgScope::System => "{commonprograms}",
PkgScope::User => "{userprograms}",
PkgScope::Ask => "{autoprograms}",
}
}
fn iss_dual_dest(
src_quoted: &str,
dest: &str,
component: &str,
arch_check: Option<&str>,
is_dir: bool,
) -> String {
let dir_flags = if is_dir {
" recursesubdirs createallsubdirs"
} else {
""
};
let arch = arch_check
.map(|c| format!(" Check: {c};"))
.unwrap_or_default();
format!(
"Source: \"{src_quoted}\"; DestDir: \"{dest}\"; \
Components: {component};{arch} \
Flags: ignoreversion overwritereadonly{dir_flags}\r\n"
)
}
fn iss_admin_only(
scope: PkgScope,
src_quoted: &str,
system_dest: &str,
component: &str,
arch_check: Option<&str>,
is_dir: bool,
) -> String {
let dir_flags = if is_dir {
" recursesubdirs createallsubdirs"
} else {
""
};
let arch_clause = arch_check.map(|c| format!(" and {c}")).unwrap_or_default();
match scope {
PkgScope::System | PkgScope::User => {
let arch = arch_check
.map(|c| format!(" Check: {c};"))
.unwrap_or_default();
format!(
"Source: \"{src_quoted}\"; DestDir: \"{system_dest}\"; \
Components: {component};{arch} \
Flags: ignoreversion overwritereadonly{dir_flags}\r\n"
)
}
PkgScope::Ask => format!(
"Source: \"{src_quoted}\"; DestDir: \"{system_dest}\"; \
Components: {component}; Check: IsAdminInstallMode{arch_clause}; \
Flags: ignoreversion overwritereadonly{dir_flags}\r\n"
),
}
}
fn iss_uninstall_lines(
fmt: &PkgFormat,
plugin_name: &str,
scope: PkgScope,
component_prefix: Option<&str>,
) -> Vec<String> {
let name = iss_escape(plugin_name);
let comp = |suffix: &str| -> String {
match component_prefix {
Some(prefix) => format!("{prefix}\\{suffix}"),
None => suffix.to_string(),
}
};
match fmt {
PkgFormat::Vst3 => {
let path = format!("{}\\VST3\\{name}.vst3", scoped_cf(scope));
let component = comp("vst3");
vec![format!(
"Type: filesandordirs; Name: \"{path}\"; Components: {component}"
)]
}
PkgFormat::Lv2 => {
use crate::commands::package::stage::lv2_slug;
let slug = lv2_slug(plugin_name);
let lv2_root = match scope {
PkgScope::System => "{commoncf}\\LV2",
PkgScope::User => "{userappdata}\\LV2",
PkgScope::Ask => "{autoappdata}\\LV2",
};
let path = format!("{lv2_root}\\{slug}.lv2");
let component = comp("lv2");
vec![format!(
"Type: filesandordirs; Name: \"{path}\"; Components: {component}"
)]
}
PkgFormat::Aax => {
let component = comp("aax");
vec![format!(
"Type: filesandordirs; Name: \"{{commoncf}}\\Avid\\Audio\\Plug-Ins\\{name}.aaxplugin\"; Components: {component}"
)]
}
_ => Vec::new(),
}
}
fn run_iscc(iss_path: &Path) -> Res {
let iscc = locate_iscc().ok_or(
"ISCC.exe not found. Install Inno Setup 6 from https://jrsoftware.org/isinfo.php \
or pass --no-installer to skip installer generation.",
)?;
eprintln!(" iscc: {}", iss_path.display());
let status = Command::new(&iscc).arg(iss_path).status()?;
if !status.success() {
return Err("ISCC.exe failed".into());
}
Ok(())
}
fn iss_escape(s: &str) -> String {
s.replace('"', "\"\"")
}
fn iss_escape_path(p: &Path) -> String {
let s = p.display().to_string();
let s = s.replace('/', "\\");
iss_escape(&s)
}
pub(crate) fn doctor() {
match locate_iscc() {
Some(p) => eprintln!(
" {} Inno Setup 6 (ISCC.exe) at {}",
tag_ok(),
p.display()
),
None => eprintln!(
" {} ISCC.exe not found — install Inno Setup 6 to produce installers",
tag_warn()
),
}
match locate_signtool() {
Some(p) => eprintln!(" {} signtool.exe at {}", tag_ok(), p.display()),
None => eprintln!(
" {} signtool.exe not found — install Windows 10/11 SDK for Authenticode",
tag_warn()
),
}
match locate_wraptool() {
Some(p) => eprintln!(" {} wraptool.exe (PACE) at {}", tag_ok(), p.display()),
None => eprintln!(
" {} wraptool.exe not found — only needed for signed AAX builds",
tag_info()
),
}
let has_rust_arm64 = rustup_has_target("aarch64-pc-windows-msvc");
let has_msvc_arm64 = has_arm64_msvc_toolchain();
match (has_rust_arm64, has_msvc_arm64) {
(true, true) => eprintln!(
" {} ARM64 cross-compile available — `cargo truce package` will produce dual-arch installers by default",
tag_ok()
),
(true, false) => eprintln!(
" {} Rust has aarch64-pc-windows-msvc but VS is missing the ARM64 MSVC toolchain — C++ shims won't cross-compile. Install \"MSVC v143 - VS 2022 C++ ARM64/ARM64EC build tools\" via the VS Installer, or pass `--host-only` to skip ARM64.",
tag_warn()
),
(false, true) => eprintln!(
" {} VS has ARM64 MSVC but the Rust target isn't installed — run: rustup target add aarch64-pc-windows-msvc (or pass `--host-only` to skip)",
tag_warn()
),
(false, false) => eprintln!(
" {} ARM64 cross-compile not set up. `cargo truce package` defaults to universal and will fail without it — add the Rust target and the VS ARM64 toolchain, or pass `--host-only` to skip ARM64.",
tag_warn()
),
}
}
fn has_arm64_msvc_toolchain() -> bool {
for vs_root in crate::vs_install_paths() {
let msvc_root = vs_root.join(r"VC\Tools\MSVC");
if let Ok(versions) = fs::read_dir(&msvc_root) {
for v in versions.flatten() {
if v.path().join(r"lib\arm64").is_dir() {
return true;
}
}
}
}
false
}