From 1a6640c6a7e114206c8f70cb4cf7191525c116aa Mon Sep 17 00:00:00 2001 From: Haihan Jiang Date: Tue, 26 May 2026 11:45:51 -0700 Subject: [PATCH] Add manifest override CLI options --- Cargo.lock | 1 + README.md | 7 + pack-cli/Cargo.toml | 1 + pack-cli/src/main.rs | 348 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 345 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 62adc8b..b8cbb93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -680,6 +680,7 @@ name = "pack-cli" version = "0.1.0" dependencies = [ "pack-api", + "xml", ] [[package]] diff --git a/README.md b/README.md index 2ca80dd..2deba29 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,13 @@ $ cargo run -p pack-cli ./watchface ./package # For custom signing, pass a .pem file as a third CLI argument. ``` +Build-time manifest overrides can be passed before the input directory: + +```sh +$ cargo run -p pack-cli -- --version-code 42 --version-name 1.0.0 \ + --rename-manifest-package com.example.watchface ./watchface ./package +``` +
diff --git a/pack-cli/Cargo.toml b/pack-cli/Cargo.toml index 49f2219..38d9626 100644 --- a/pack-cli/Cargo.toml +++ b/pack-cli/Cargo.toml @@ -5,3 +5,4 @@ edition = "2021" [dependencies] pack-api = { path = "../pack-api", features = ["cert-gen"] } +xml = "0.8.20" diff --git a/pack-cli/src/main.rs b/pack-cli/src/main.rs index 22b8b4b..aeeaf23 100644 --- a/pack-cli/src/main.rs +++ b/pack-cli/src/main.rs @@ -14,11 +14,22 @@ use pack_api::{compile_and_sign_aab, compile_and_sign_apk, Keys, PackError, Package, Result}; use res_dir::read_res_dir; +use std::borrow::Cow; +use std::io::{Cursor, Write}; use std::path::PathBuf; use std::{env, fs}; +use xml::attribute::{Attribute, OwnedAttribute}; +use xml::name::OwnedName; +use xml::namespace::Namespace; +use xml::reader::{EventReader, XmlEvent as ReaderXmlEvent}; +use xml::writer::{EmitterConfig, EventWriter, XmlEvent as WriterXmlEvent}; pub mod res_dir; +const ANDROID_NAMESPACE: &str = "http://schemas.android.com/apk/res/android"; +const USAGE: &str = "Usage: pack-cli [--version-code CODE] [--version-name NAME] \ + [--rename-manifest-package PACKAGE] [keys.pem]"; + /// Run from a watch face directory to build signed APK and AAB files. /// /// ``` @@ -45,18 +56,12 @@ fn main() { } fn pack_main() -> Result<()> { - let in_dir = env::args() - .nth(1) - .ok_or(PackError::Cli("Input directory path not provided.".into()))?; - let out_path = env::args() - .nth(2) - .ok_or(PackError::Cli("Output APK path not provided.".into()))?; - let out_apk_path = PathBuf::from(&out_path).with_extension("apk"); - let out_aab_path = PathBuf::from(&out_path).with_extension("aab"); + let args = parse_cli_args(env::args().skip(1))?; + let out_apk_path = PathBuf::from(&args.out_path).with_extension("apk"); + let out_aab_path = PathBuf::from(&args.out_path).with_extension("aab"); let signing_keys = - env::args() - .nth(3) + args.pem_path .map_or_else(Keys::generate_random_testing_keys, |pem_path| { let key_pem_bytes = fs::read(pem_path)?; let key_pem_str = String::from_utf8(key_pem_bytes) @@ -64,10 +69,11 @@ fn pack_main() -> Result<()> { Keys::from_combined_pem_string(&key_pem_str) })?; - let mut in_path = PathBuf::from(&in_dir); + let mut in_path = PathBuf::from(&args.in_dir); in_path.push("AndroidManifest.xml"); - let android_manifest = fs::read(&in_path)?; + let android_manifest = + apply_manifest_overrides(&fs::read(&in_path)?, &args.manifest_overrides)?; in_path.pop(); in_path.push("res"); @@ -90,3 +96,321 @@ fn pack_main() -> Result<()> { Ok(()) } + +struct CliArgs { + in_dir: String, + out_path: String, + pem_path: Option, + manifest_overrides: ManifestOverrides, +} + +#[derive(Default)] +struct ManifestOverrides { + package_name: Option, + version_code: Option, + version_name: Option, +} + +impl ManifestOverrides { + fn is_empty(&self) -> bool { + self.package_name.is_none() && self.version_code.is_none() && self.version_name.is_none() + } +} + +fn parse_cli_args(args: impl IntoIterator) -> Result { + let mut positional = vec![]; + let mut manifest_overrides = ManifestOverrides::default(); + let mut args = args.into_iter(); + + while let Some(arg) = args.next() { + if let Some(value) = arg.strip_prefix("--version-code=") { + manifest_overrides.version_code = Some(value.into()); + continue; + } + if let Some(value) = arg.strip_prefix("--version-name=") { + manifest_overrides.version_name = Some(value.into()); + continue; + } + if let Some(value) = arg.strip_prefix("--rename-manifest-package=") { + manifest_overrides.package_name = Some(value.into()); + continue; + } + + match arg.as_str() { + "--version-code" => { + manifest_overrides.version_code = Some(next_option_value(&mut args, &arg)?); + } + "--version-name" => { + manifest_overrides.version_name = Some(next_option_value(&mut args, &arg)?); + } + "--rename-manifest-package" => { + manifest_overrides.package_name = Some(next_option_value(&mut args, &arg)?); + } + "--help" | "-h" => return Err(PackError::Cli(USAGE.into())), + _ if arg.starts_with("--") => { + return Err(PackError::Cli(format!( + "Unknown option \"{arg}\".\n{USAGE}" + ))); + } + _ => positional.push(arg), + } + } + + match positional.as_slice() { + [in_dir, out_path] => Ok(CliArgs { + in_dir: in_dir.into(), + out_path: out_path.into(), + pem_path: None, + manifest_overrides, + }), + [in_dir, out_path, pem_path] => Ok(CliArgs { + in_dir: in_dir.into(), + out_path: out_path.into(), + pem_path: Some(pem_path.into()), + manifest_overrides, + }), + _ => Err(PackError::Cli(format!( + "Expected and optional [keys.pem].\n{USAGE}" + ))), + } +} + +fn next_option_value(args: &mut impl Iterator, option: &str) -> Result { + args.next() + .ok_or_else(|| PackError::Cli(format!("{option} requires a value.\n{USAGE}"))) +} + +fn apply_manifest_overrides( + manifest: &[u8], + manifest_overrides: &ManifestOverrides, +) -> Result> { + if manifest_overrides.is_empty() { + return Ok(manifest.into()); + } + + let parser = EventReader::new(Cursor::new(manifest)); + let mut output = vec![]; + { + let mut writer = EmitterConfig::new() + .write_document_declaration(false) + .create_writer(&mut output); + let mut updated_manifest = false; + + for event in parser { + let event = event.map_err(PackError::XmlParsingFailed)?; + match event { + ReaderXmlEvent::StartDocument { .. } | ReaderXmlEvent::EndDocument => {} + ReaderXmlEvent::StartElement { + name, + mut attributes, + mut namespace, + } if name.local_name == "manifest" && !updated_manifest => { + updated_manifest = true; + apply_manifest_attribute_overrides( + &mut attributes, + &mut namespace, + manifest_overrides, + ); + write_start_element(&mut writer, &name, &attributes, &namespace)?; + } + _ => { + if let Some(event) = event.as_writer_event() { + writer.write(event).map_err(manifest_rewrite_error)?; + } + } + } + } + } + + Ok(output) +} + +fn apply_manifest_attribute_overrides( + attributes: &mut Vec, + namespace: &mut Namespace, + manifest_overrides: &ManifestOverrides, +) { + if let Some(package_name) = &manifest_overrides.package_name { + upsert_attribute(attributes, OwnedName::local("package"), package_name); + } + + if manifest_overrides.version_code.is_some() || manifest_overrides.version_name.is_some() { + namespace.force_put("android", ANDROID_NAMESPACE); + } + + if let Some(version_code) = &manifest_overrides.version_code { + upsert_attribute( + attributes, + OwnedName::qualified("versionCode", ANDROID_NAMESPACE, Some("android")), + version_code, + ); + } + + if let Some(version_name) = &manifest_overrides.version_name { + upsert_attribute( + attributes, + OwnedName::qualified("versionName", ANDROID_NAMESPACE, Some("android")), + version_name, + ); + } +} + +fn upsert_attribute(attributes: &mut Vec, name: OwnedName, value: &str) { + if let Some(attribute) = attributes.iter_mut().find(|attribute| { + attribute.name.local_name == name.local_name && attribute.name.namespace == name.namespace + }) { + attribute.name = name; + attribute.value = value.into(); + } else { + attributes.push(OwnedAttribute::new(name, value)); + } +} + +fn write_start_element( + writer: &mut EventWriter, + name: &OwnedName, + attributes: &[OwnedAttribute], + namespace: &Namespace, +) -> Result<()> { + let attributes: Vec> = attributes + .iter() + .map(|attribute| attribute.borrow()) + .collect(); + writer + .write(WriterXmlEvent::StartElement { + name: name.borrow(), + attributes: Cow::Owned(attributes), + namespace: Cow::Owned(namespace.clone()), + }) + .map_err(manifest_rewrite_error) +} + +fn manifest_rewrite_error(err: xml::writer::Error) -> PackError { + PackError::Cli(format!("Failed to rewrite AndroidManifest.xml: {err}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MANIFEST: &[u8] = br#" + + + + "#; + + const MANIFEST_WITH_VALUES: &[u8] = br#" + + + + "#; + + #[test] + fn parses_manifest_override_options() { + let args = parse_cli_args( + [ + "--version-code", + "42", + "--version-name=2.0", + "--rename-manifest-package", + "com.example.new", + "input", + "output", + "keys.pem", + ] + .into_iter() + .map(String::from), + ) + .unwrap(); + + assert_eq!(args.in_dir, "input"); + assert_eq!(args.out_path, "output"); + assert_eq!(args.pem_path.as_deref(), Some("keys.pem")); + assert_eq!(args.manifest_overrides.version_code.as_deref(), Some("42")); + assert_eq!(args.manifest_overrides.version_name.as_deref(), Some("2.0")); + assert_eq!( + args.manifest_overrides.package_name.as_deref(), + Some("com.example.new") + ); + } + + #[test] + fn injects_missing_manifest_attributes() { + let output = apply_manifest_overrides( + MANIFEST, + &ManifestOverrides { + package_name: Some("com.example.new".into()), + version_code: Some("42".into()), + version_name: Some("2.0".into()), + }, + ) + .unwrap(); + + assert_eq!( + manifest_attribute(&output, None, "package").as_deref(), + Some("com.example.new") + ); + assert_eq!( + manifest_attribute(&output, Some(ANDROID_NAMESPACE), "versionCode").as_deref(), + Some("42") + ); + assert_eq!( + manifest_attribute(&output, Some(ANDROID_NAMESPACE), "versionName").as_deref(), + Some("2.0") + ); + } + + #[test] + fn replaces_existing_manifest_attributes() { + let output = apply_manifest_overrides( + MANIFEST_WITH_VALUES, + &ManifestOverrides { + package_name: None, + version_code: Some("7".into()), + version_name: Some("7.1".into()), + }, + ) + .unwrap(); + + assert_eq!( + manifest_attribute(&output, None, "package").as_deref(), + Some("com.example.old") + ); + assert_eq!( + manifest_attribute(&output, Some(ANDROID_NAMESPACE), "versionCode").as_deref(), + Some("7") + ); + assert_eq!( + manifest_attribute(&output, Some(ANDROID_NAMESPACE), "versionName").as_deref(), + Some("7.1") + ); + } + + fn manifest_attribute(manifest: &[u8], namespace: Option<&str>, name: &str) -> Option { + let parser = EventReader::new(Cursor::new(manifest)); + for event in parser { + if let ReaderXmlEvent::StartElement { + name: element_name, + attributes, + .. + } = event.unwrap() + { + if element_name.local_name != "manifest" { + continue; + } + return attributes + .iter() + .find(|attribute| { + attribute.name.local_name == name + && attribute.name.namespace.as_deref() == namespace + }) + .map(|attribute| attribute.value.clone()); + } + } + None + } +}