makepad/tools/cargo_makepad/src/desktop.rs

592 lines
21 KiB
Rust

use crate::makepad_shell::{shell_env_cap, write_text};
use crate::utils::{
get_build_crate_from_args, get_crate_dir, get_package_binary_name, get_profile_from_args,
get_target_from_args, resolve_app_icon_env, AppIconEnv, APP_ICON_ENV_VARS, APP_ICON_IDX_1024,
APP_ICON_IDX_512,
};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Default, Clone)]
struct MacosSignOptions {
enabled: bool,
cert: Option<String>,
entitlements: Option<PathBuf>,
debugger: bool,
get_task_allow: bool,
runtime: bool,
}
fn is_windows_target(args: &[String]) -> bool {
get_target_from_args(args)
.map(|t| t.contains("windows"))
.unwrap_or(cfg!(target_os = "windows"))
}
fn is_macos_target(args: &[String]) -> bool {
get_target_from_args(args)
.map(|t| t.contains("apple-darwin"))
.unwrap_or(cfg!(target_os = "macos"))
}
fn is_linux_target(args: &[String]) -> bool {
get_target_from_args(args)
.map(|t| t.contains("linux"))
.unwrap_or(cfg!(target_os = "linux"))
}
fn binary_path(args: &[String], binary_name: &str) -> PathBuf {
let profile = get_profile_from_args(args);
let mut path = cargo_target_dir();
if let Some(target) = get_target_from_args(args) {
path.push(target);
}
path.push(profile);
path.push(binary_name);
if is_windows_target(args) {
path.set_extension("exe");
}
path
}
fn cargo_target_dir() -> PathBuf {
if let Some(target_dir) = std::env::var_os("CARGO_TARGET_DIR") {
let target_dir = PathBuf::from(target_dir);
if target_dir.is_absolute() {
target_dir
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(target_dir)
}
} else {
PathBuf::from("target")
}
}
/// The executable's icon resource, made in-process (no rc.exe/windres) from the
/// same source as the macOS bundle in `write_macos_app_bundle`:
/// `resources/icon.icns`, else the largest `icon_*.png`.
fn write_windows_icon_resource(
args: &[String],
icon_env: &AppIconEnv,
build_crate: &str,
) -> Result<Option<PathBuf>, String> {
use makepad_win_resource::{app_link_input, LinkFormat};
let target = get_target_from_args(args).unwrap_or_else(|| {
format!("{}-pc-windows-msvc", std::env::consts::ARCH)
});
let env = if target.ends_with("-gnu") || target.ends_with("-gnullvm") { "gnu" } else { "msvc" };
let arch = target.split('-').next().unwrap_or_default();
let Some(format) = LinkFormat::for_target("windows", env, arch)? else {
return Ok(None);
};
let icns = get_crate_dir(build_crate)?.join("resources/icon.icns");
let source = if icns.is_file() {
icns
} else {
PathBuf::from(&icon_env[APP_ICON_IDX_1024])
};
let bytes = fs::read(&source).map_err(|e| format!("failed to read {:?}: {e}", source))?;
let input = app_link_input(format, Some(&bytes), None)
.map_err(|e| format!("Windows icon from {:?}: {e}", source))?;
let out_dir = PathBuf::from("target/makepad-desktop/windows-res").join(build_crate);
fs::create_dir_all(&out_dir).map_err(|e| format!("failed to create {:?}: {e}", out_dir))?;
let path = out_dir.join(format!("app_icon.{}", format.extension()));
fs::write(&path, input).map_err(|e| format!("failed to write {:?}: {e}", path))?;
Ok(Some(path))
}
fn add_windows_icon_link_arg(
cmd: &mut Command,
args: &[String],
icon_env: &AppIconEnv,
build_crate: &str,
) -> Result<(), String> {
if !is_windows_target(args) {
return Ok(());
}
let Some(res) = write_windows_icon_resource(args, icon_env, build_crate)? else {
return Ok(());
};
let res_abs = res.canonicalize().unwrap_or(res);
let res_arg = format!("-C link-arg={}", res_abs.to_string_lossy());
let mut rustflags = std::env::var("RUSTFLAGS").unwrap_or_default();
if !rustflags.is_empty() {
rustflags.push(' ');
}
rustflags.push_str(&res_arg);
cmd.env("RUSTFLAGS", rustflags);
Ok(())
}
fn write_macos_app_bundle(
args: &[String],
build_crate: &str,
icon_env: &AppIconEnv,
) -> Result<(), String> {
if !is_macos_target(args) {
return Ok(());
}
let binary_name =
get_package_binary_name(build_crate).unwrap_or_else(|| build_crate.to_string());
let bin = binary_path(args, &binary_name);
if !bin.is_file() {
eprintln!(
"warning: built binary not found at {} (package: {}, binary: {})",
bin.display(),
build_crate,
binary_name
);
return Ok(());
}
let profile = get_profile_from_args(args);
let app_dir = macos_app_bundle_path_for(&binary_name, &profile);
let macos_dir = app_dir.join("Contents/MacOS");
let resources_dir = app_dir.join("Contents/Resources");
fs::create_dir_all(&macos_dir).map_err(|e| format!("failed to create {:?}: {e}", macos_dir))?;
fs::create_dir_all(&resources_dir)
.map_err(|e| format!("failed to create {:?}: {e}", resources_dir))?;
let dst_bin = macos_dir.join(&binary_name);
fs::copy(&bin, &dst_bin)
.map_err(|e| format!("failed to copy {:?} to {:?}: {e}", bin, dst_bin))?;
let crate_dir = get_crate_dir(build_crate)?;
let icns_src = crate_dir.join("resources/icon.icns");
if icns_src.is_file() {
fs::copy(&icns_src, resources_dir.join("AppIcon.icns"))
.map_err(|e| format!("failed to copy {:?}: {e}", icns_src))?;
} else {
let fallback = Path::new(&icon_env[APP_ICON_IDX_1024]);
fs::copy(fallback, resources_dir.join("AppIcon.png"))
.map_err(|e| format!("failed to copy {:?}: {e}", fallback))?;
}
let plist = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n\
<dict>\n\
<key>CFBundleExecutable</key><string>{binary_name}</string>\n\
<key>CFBundleIdentifier</key><string>dev.makepad.{build_crate}</string>\n\
<key>CFBundleName</key><string>{binary_name}</string>\n\
<key>CFBundleDisplayName</key><string>{binary_name}</string>\n\
<key>CFBundlePackageType</key><string>APPL</string>\n\
<key>CFBundleVersion</key><string>1.0.0</string>\n\
<key>CFBundleShortVersionString</key><string>1.0.0</string>\n\
<key>CFBundleIconFile</key><string>AppIcon</string>\n\
<key>GCSupportsControllerUserInteraction</key><true/>\n\
<key>GCSupportedGameControllers</key>\n\
<array>\n\
<dict>\n\
<key>ProfileName</key><string>ExtendedGamepad</string>\n\
</dict>\n\
</array>\n\
</dict>\n\
</plist>\n"
);
let plist_path = app_dir.join("Contents/Info.plist");
fs::write(&plist_path, plist).map_err(|e| format!("failed to write Info.plist: {e}"))?;
println!("[cargo-makepad] macOS app bundle: {}", app_dir.display());
println!("[cargo-makepad] macOS app binary: {}", dst_bin.display());
println!(
"[cargo-makepad] macOS app resources: {}",
resources_dir.display()
);
println!("[cargo-makepad] macOS info plist: {}", plist_path.display());
Ok(())
}
fn macos_app_bundle_path_for(binary_name: &str, profile: &str) -> PathBuf {
cargo_target_dir()
.join("makepad-desktop/macos")
.join(profile)
.join(format!("{binary_name}.app"))
}
pub(crate) fn resolve_codesign_identity(cert: Option<&str>) -> Result<String, String> {
if let Some(cert) = cert {
return Ok(cert.to_string());
}
// A standing choice for machines with several identities.
if let Some(cert) = std::env::var("MAKEPAD_CODESIGN_IDENTITY").ok().filter(|cert| !cert.trim().is_empty()) {
return Ok(cert.trim().to_string());
}
let cwd = std::env::current_dir().unwrap();
let identities = shell_env_cap(
&[],
&cwd,
"security",
&["find-identity", "-v", "-p", "codesigning"],
)?;
pick_codesign_identity(&identities)
}
/// The identity to sign with from `security find-identity -v` output: the
/// Apple Development one, or the only one there is. Several certificates
/// under one name (a renewed certificate beside the old one) are the same
/// signer as far as a designated requirement is concerned, which names the
/// certificate's subject, not the certificate: the first is taken, by hash,
/// since the shared name alone is ambiguous to codesign.
fn pick_codesign_identity(listing: &str) -> Result<String, String> {
let mut apple_development: Vec<(String, String)> = Vec::new();
let mut all: Vec<(String, String)> = Vec::new();
for line in listing.lines() {
let name = line
.split('"')
.nth(1)
.map(str::trim)
.filter(|value| !value.is_empty());
let hash = line
.split_whitespace()
.nth(1)
.filter(|hash| hash.len() == 40 && hash.chars().all(|c| c.is_ascii_hexdigit()));
if let (Some(name), Some(hash)) = (name, hash) {
all.push((hash.to_string(), name.to_string()));
if name.starts_with("Apple Development:") {
apple_development.push((hash.to_string(), name.to_string()));
}
}
}
let pick = |mut found: Vec<(String, String)>| {
if found.len() == 1 {
let (_, name) = found.remove(0);
Some(name)
} else if found.windows(2).all(|pair| pair[0].1 == pair[1].1) {
found.first().map(|(hash, _)| hash.clone())
} else {
None
}
};
match (apple_development.is_empty(), all.is_empty()) {
(_, true) => Err(
"no codesigning identity found; pass --cert=\"Apple Development: ...\" explicitly"
.to_string(),
),
(false, _) => pick(apple_development).ok_or_else(|| {
"multiple Apple Development identities found; pass --cert=<identity or hash> or set MAKEPAD_CODESIGN_IDENTITY"
.to_string()
}),
(true, false) => pick(all).ok_or_else(|| {
"multiple codesigning identities and no Apple Development one; pass --cert=<identity or hash>"
.to_string()
}),
}
}
#[cfg(test)]
mod codesign_identity_tests {
use super::pick_codesign_identity;
#[test]
fn a_renewed_certificate_beside_the_old_one_is_one_signer() {
let listing = " 1) 82B72FFCA0221B8B84962F0D6D10B2743ED9E118 \"Apple Development: A B (TEAM)\"\n 2) ED88B9ACB31D482F8746EEE08B057BE803095162 \"Apple Development: A B (TEAM)\"\n 2 valid identities found\n";
assert_eq!(pick_codesign_identity(listing).unwrap(), "82B72FFCA0221B8B84962F0D6D10B2743ED9E118");
}
#[test]
fn one_identity_is_named_and_different_names_need_a_choice() {
let one = " 1) 82B72FFCA0221B8B84962F0D6D10B2743ED9E118 \"Apple Development: A B (TEAM)\"\n";
assert_eq!(pick_codesign_identity(one).unwrap(), "Apple Development: A B (TEAM)");
let two = " 1) 82B72FFCA0221B8B84962F0D6D10B2743ED9E118 \"Apple Development: A B (TEAM)\"\n 2) ED88B9ACB31D482F8746EEE08B057BE803095162 \"Apple Development: C D (TEAM2)\"\n";
assert!(pick_codesign_identity(two).is_err());
assert!(pick_codesign_identity(" 0 valid identities found\n").is_err());
}
}
fn maybe_write_generated_entitlements(
args: &[String],
build_crate: &str,
binary_name: &str,
sign: &MacosSignOptions,
) -> Result<Option<PathBuf>, String> {
if let Some(path) = &sign.entitlements {
return Ok(Some(path.clone()));
}
if !sign.debugger && !sign.get_task_allow {
return Ok(None);
}
let profile = get_profile_from_args(args);
let out_dir = cargo_target_dir()
.join("makepad-desktop/macos-sign")
.join(profile)
.join(build_crate);
fs::create_dir_all(&out_dir).map_err(|e| format!("failed to create {:?}: {e}", out_dir))?;
let out_path = out_dir.join(format!("{binary_name}.entitlements"));
let mut xml = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n\
<dict>\n",
);
if sign.debugger {
xml.push_str(" <key>com.apple.security.cs.debugger</key>\n <true/>\n");
}
if sign.get_task_allow {
xml.push_str(" <key>com.apple.security.get-task-allow</key>\n <true/>\n");
}
xml.push_str("</dict>\n</plist>\n");
write_text(&out_path, &xml)?;
Ok(Some(out_path))
}
pub(crate) fn codesign_path(
path: &Path,
identity: &str,
entitlements: Option<&Path>,
runtime: bool,
) -> Result<(), String> {
let cwd = std::env::current_dir().unwrap();
let mut args = vec![
"--force".to_string(),
"--timestamp=none".to_string(),
"--sign".to_string(),
identity.to_string(),
];
if runtime {
args.push("--options".to_string());
args.push("runtime".to_string());
}
if let Some(entitlements) = entitlements {
args.push("--entitlements".to_string());
args.push(entitlements.to_string_lossy().to_string());
args.push("--generate-entitlement-der".to_string());
}
args.push(path.to_string_lossy().to_string());
let refs = args.iter().map(|v| v.as_str()).collect::<Vec<_>>();
shell_env_cap(&[], &cwd, "codesign", &refs)?;
Ok(())
}
fn sign_macos_artifacts(args: &[String], sign: &MacosSignOptions) -> Result<(), String> {
if !is_macos_target(args) || !sign.enabled {
return Ok(());
}
let build_crate = get_build_crate_from_args(args)?;
let binary_name =
get_package_binary_name(build_crate).unwrap_or_else(|| build_crate.to_string());
let identity = resolve_codesign_identity(sign.cert.as_deref())?;
let entitlements = maybe_write_generated_entitlements(args, build_crate, &binary_name, sign)?;
let entitlements_ref = entitlements.as_deref();
let bin = binary_path(args, &binary_name);
if !bin.is_file() {
return Err(format!(
"built binary not found at {} (package: {}, binary: {})",
bin.display(),
build_crate,
binary_name
));
}
codesign_path(
&bin,
&identity,
entitlements_ref,
sign.runtime || sign.debugger,
)?;
println!("[cargo-makepad] macOS signed binary: {}", bin.display());
let profile = get_profile_from_args(args);
let app_dir = macos_app_bundle_path_for(&binary_name, &profile);
if app_dir.is_dir() {
codesign_path(
&app_dir,
&identity,
entitlements_ref,
sign.runtime || sign.debugger,
)?;
println!(
"[cargo-makepad] macOS signed app bundle: {}",
app_dir.display()
);
}
Ok(())
}
fn write_linux_desktop_entry(
args: &[String],
build_crate: &str,
icon_env: &AppIconEnv,
) -> Result<(), String> {
if !is_linux_target(args) {
return Ok(());
}
let binary_name =
get_package_binary_name(build_crate).unwrap_or_else(|| build_crate.to_string());
let bin = binary_path(args, &binary_name);
if !bin.is_file() {
eprintln!(
"warning: built binary not found at {} (package: {}, binary: {})",
bin.display(),
build_crate,
binary_name
);
return Ok(());
}
let profile = get_profile_from_args(args);
let out = PathBuf::from("target/makepad-desktop/linux").join(profile);
fs::create_dir_all(&out).map_err(|e| format!("failed to create {:?}: {e}", out))?;
let icon = Path::new(&icon_env[APP_ICON_IDX_512]);
let icon_dst = out.join(format!("{binary_name}.png"));
fs::copy(icon, &icon_dst)
.map_err(|e| format!("failed to copy {:?} to {:?}: {e}", icon, icon_dst))?;
let desktop_path = out.join(format!("{binary_name}.desktop"));
let exec_path = bin.canonicalize().unwrap_or(bin);
let icon_path = icon_dst.canonicalize().unwrap_or(icon_dst.clone());
let body = format!(
"[Desktop Entry]\nType=Application\nName={binary_name}\nExec={}\nIcon={}\nTerminal=false\nCategories=Development;\n",
exec_path.to_string_lossy(),
icon_path.to_string_lossy(),
);
fs::write(&desktop_path, body)
.map_err(|e| format!("failed to write {:?}: {e}", desktop_path))?;
println!(
"[cargo-makepad] Linux desktop entry: {}",
desktop_path.display()
);
println!("[cargo-makepad] Linux desktop icon: {}", icon_dst.display());
Ok(())
}
fn post_build_assets(args: &[String], icon_env: &AppIconEnv) -> Result<(), String> {
let build_crate = get_build_crate_from_args(args)?;
write_macos_app_bundle(args, build_crate, icon_env)?;
write_linux_desktop_entry(args, build_crate, icon_env)?;
Ok(())
}
fn run_cargo(
subcommand: &str,
args: &[String],
icon_env: Option<AppIconEnv>,
sign: &MacosSignOptions,
) -> Result<(), String> {
let mut cmd = Command::new("cargo");
cmd.arg(subcommand);
cmd.args(args);
if let Some(icon) = icon_env.as_ref() {
for (var, value) in APP_ICON_ENV_VARS.iter().zip(icon.iter()) {
cmd.env(var, value);
}
let build_crate = get_build_crate_from_args(args)?;
add_windows_icon_link_arg(&mut cmd, args, icon, build_crate)?;
}
let status = cmd
.status()
.map_err(|e| format!("failed to run cargo {subcommand}: {e}"))?;
if !status.success() {
return Err(format!("cargo {subcommand} failed with status {status}"));
}
if subcommand == "build" {
if let Some(icon) = icon_env.as_ref() {
post_build_assets(args, icon)?;
}
sign_macos_artifacts(args, sign)?;
}
Ok(())
}
fn resolve_icons_for_args(args: &[String]) -> Result<Option<AppIconEnv>, String> {
let build_crate = get_build_crate_from_args(args)?;
resolve_app_icon_env(build_crate)
}
pub fn handle_desktop(args: &[String]) -> Result<(), String> {
if args.is_empty() {
return Err("desktop requires a subcommand: build, run, check, bundle, sign".to_string());
}
match args[0].as_str() {
"build" => {
let (sign, cargo_args) = parse_macos_sign_options(&args[1..])?;
let icon_env = resolve_icons_for_args(&cargo_args)?;
run_cargo("build", &cargo_args, icon_env, &sign)
}
"run" => {
let (sign, cargo_args) = parse_macos_sign_options(&args[1..])?;
if sign.enabled {
return Err(
"desktop run does not support signing inline; use `cargo makepad desktop build --sign ...` followed by the signed binary"
.to_string(),
);
}
let icon_env = resolve_icons_for_args(&cargo_args)?;
run_cargo("run", &cargo_args, icon_env, &sign)
}
"check" => {
let (sign, cargo_args) = parse_macos_sign_options(&args[1..])?;
if sign.enabled {
return Err("desktop check cannot sign artifacts".to_string());
}
let icon_env = resolve_icons_for_args(&cargo_args)?;
run_cargo("check", &cargo_args, icon_env, &sign)
}
"bundle" => crate::desktop_bundle::handle_desktop_bundle(&args[1..]),
"sign" => {
let (mut sign, cargo_args) = parse_macos_sign_options(&args[1..])?;
sign.enabled = true;
sign_macos_artifacts(&cargo_args, &sign)
}
cmd => Err(format!("{cmd} is not a valid desktop command")),
}
}
fn parse_macos_sign_options(args: &[String]) -> Result<(MacosSignOptions, Vec<String>), String> {
let mut sign = MacosSignOptions::default();
let mut cargo_args = Vec::new();
for arg in args {
if arg == "--sign" {
sign.enabled = true;
} else if let Some(value) = arg.strip_prefix("--cert=") {
sign.enabled = true;
sign.cert = Some(value.to_string());
} else if let Some(value) = arg.strip_prefix("--entitlements=") {
sign.enabled = true;
sign.entitlements = Some(PathBuf::from(value));
} else if arg == "--debugger" {
sign.enabled = true;
sign.debugger = true;
} else if arg == "--get-task-allow" {
sign.enabled = true;
sign.get_task_allow = true;
} else if arg == "--runtime" {
sign.enabled = true;
sign.runtime = true;
} else {
cargo_args.push(arg.clone());
}
}
if sign.entitlements.is_some() && (sign.debugger || sign.get_task_allow) {
return Err(
"use either --entitlements=<path> or generated entitlement flags (--debugger/--get-task-allow), not both"
.to_string(),
);
}
Ok((sign, cargo_args))
}