Procházet zdrojové kódy

feat(cli): add `android build` command (#4999)

Lucas Fernandes Nogueira před 3 roky
rodič
revize
4c9ea450c3

+ 6 - 0
.changes/cli-android-build.md

@@ -0,0 +1,6 @@
+---
+"cli.rs": minor
+"cli.js": minor
+---
+
+Added `android build` command.

+ 1 - 1
tooling/cli/Cargo.lock

@@ -250,7 +250,7 @@ dependencies = [
 [[package]]
 name = "cargo-mobile"
 version = "0.1.0"
-source = "git+https://github.com/tauri-apps/cargo-mobile?branch=dev#c30e0f1338632a9d904e0aca602408fca9d70650"
+source = "git+https://github.com/tauri-apps/cargo-mobile?branch=dev#76732050ab72f308a202aeb580bcdfa6936c4e28"
 dependencies = [
  "cocoa",
  "colored 1.9.3",

+ 103 - 93
tooling/cli/src/build.rs

@@ -58,104 +58,12 @@ pub struct Options {
 }
 
 pub fn command(mut options: Options) -> Result<()> {
-  let (merge_config, merge_config_path) = if let Some(config) = &options.config {
-    if config.starts_with('{') {
-      (Some(config.to_string()), None)
-    } else {
-      (
-        Some(
-          std::fs::read_to_string(&config)
-            .with_context(|| "failed to read custom configuration")?,
-        ),
-        Some(config.clone()),
-      )
-    }
-  } else {
-    (None, None)
-  };
-  options.config = merge_config;
-
-  let tauri_path = tauri_dir();
-  set_current_dir(&tauri_path).with_context(|| "failed to change current working directory")?;
+  let mut interface = setup(&mut options)?;
 
   let config = get_config(options.config.as_deref())?;
-
   let config_guard = config.lock().unwrap();
   let config_ = config_guard.as_ref().unwrap();
 
-  let mut interface = AppInterface::new(config_)?;
-
-  let bundle_identifier_source = match config_.find_bundle_identifier_overwriter() {
-    Some(source) if source == MERGE_CONFIG_EXTENSION_NAME => merge_config_path.unwrap_or(source),
-    Some(source) => source,
-    None => "tauri.conf.json".into(),
-  };
-
-  if config_.tauri.bundle.identifier == "com.tauri.dev" {
-    error!(
-      "You must change the bundle identifier in `{} > tauri > bundle > identifier`. The default value `com.tauri.dev` is not allowed as it must be unique across applications.",
-      bundle_identifier_source
-    );
-    std::process::exit(1);
-  }
-
-  if config_
-    .tauri
-    .bundle
-    .identifier
-    .chars()
-    .any(|ch| !(ch.is_alphanumeric() || ch == '-' || ch == '.'))
-  {
-    error!(
-      "The bundle identifier \"{}\" set in `{} > tauri > bundle > identifier`. The bundle identifier string must contain only alphanumeric characters (A–Z, a–z, and 0–9), hyphens (-), and periods (.).",
-      config_.tauri.bundle.identifier,
-      bundle_identifier_source
-    );
-    std::process::exit(1);
-  }
-
-  if let Some(before_build) = config_.build.before_build_command.clone() {
-    run_hook("beforeBuildCommand", before_build, options.debug)?;
-  }
-
-  if let AppUrl::Url(WindowUrl::App(web_asset_path)) = &config_.build.dist_dir {
-    if !web_asset_path.exists() {
-      return Err(anyhow::anyhow!(
-          "Unable to find your web assets, did you forget to build your web app? Your distDir is set to \"{:?}\".",
-          web_asset_path
-        ));
-    }
-    if web_asset_path.canonicalize()?.file_name() == Some(std::ffi::OsStr::new("src-tauri")) {
-      return Err(anyhow::anyhow!(
-            "The configured distDir is the `src-tauri` folder.
-            Please isolate your web assets on a separate folder and update `tauri.conf.json > build > distDir`.",
-          ));
-    }
-
-    let mut out_folders = Vec::new();
-    for folder in &["node_modules", "src-tauri", "target"] {
-      if web_asset_path.join(folder).is_dir() {
-        out_folders.push(folder.to_string());
-      }
-    }
-    if !out_folders.is_empty() {
-      return Err(anyhow::anyhow!(
-            "The configured distDir includes the `{:?}` {}. Please isolate your web assets on a separate folder and update `tauri.conf.json > build > distDir`.",
-            out_folders,
-            if out_folders.len() == 1 { "folder" }else { "folders" }
-          )
-        );
-    }
-  }
-
-  if options.runner.is_none() {
-    options.runner = config_.build.runner.clone();
-  }
-
-  if let Some(list) = options.features.as_mut() {
-    list.extend(config_.build.features.clone().unwrap_or_default());
-  }
-
   let app_settings = interface.app_settings();
   let interface_options = options.clone().into();
 
@@ -310,6 +218,108 @@ pub fn command(mut options: Options) -> Result<()> {
   Ok(())
 }
 
+pub fn setup(options: &mut Options) -> Result<AppInterface> {
+  let (merge_config, merge_config_path) = if let Some(config) = &options.config {
+    if config.starts_with('{') {
+      (Some(config.to_string()), None)
+    } else {
+      (
+        Some(
+          std::fs::read_to_string(&config)
+            .with_context(|| "failed to read custom configuration")?,
+        ),
+        Some(config.clone()),
+      )
+    }
+  } else {
+    (None, None)
+  };
+  options.config = merge_config;
+
+  let tauri_path = tauri_dir();
+  set_current_dir(&tauri_path).with_context(|| "failed to change current working directory")?;
+
+  let config = get_config(options.config.as_deref())?;
+
+  let config_guard = config.lock().unwrap();
+  let config_ = config_guard.as_ref().unwrap();
+
+  let interface = AppInterface::new(config_)?;
+
+  let bundle_identifier_source = match config_.find_bundle_identifier_overwriter() {
+    Some(source) if source == MERGE_CONFIG_EXTENSION_NAME => merge_config_path.unwrap_or(source),
+    Some(source) => source,
+    None => "tauri.conf.json".into(),
+  };
+
+  if config_.tauri.bundle.identifier == "com.tauri.dev" {
+    error!(
+      "You must change the bundle identifier in `{} > tauri > bundle > identifier`. The default value `com.tauri.dev` is not allowed as it must be unique across applications.",
+      bundle_identifier_source
+    );
+    std::process::exit(1);
+  }
+
+  if config_
+    .tauri
+    .bundle
+    .identifier
+    .chars()
+    .any(|ch| !(ch.is_alphanumeric() || ch == '-' || ch == '.'))
+  {
+    error!(
+      "The bundle identifier \"{}\" set in `{} > tauri > bundle > identifier`. The bundle identifier string must contain only alphanumeric characters (A–Z, a–z, and 0–9), hyphens (-), and periods (.).",
+      config_.tauri.bundle.identifier,
+      bundle_identifier_source
+    );
+    std::process::exit(1);
+  }
+
+  if let Some(before_build) = config_.build.before_build_command.clone() {
+    run_hook("beforeBuildCommand", before_build, options.debug)?;
+  }
+
+  if let AppUrl::Url(WindowUrl::App(web_asset_path)) = &config_.build.dist_dir {
+    if !web_asset_path.exists() {
+      return Err(anyhow::anyhow!(
+          "Unable to find your web assets, did you forget to build your web app? Your distDir is set to \"{:?}\".",
+          web_asset_path
+        ));
+    }
+    if web_asset_path.canonicalize()?.file_name() == Some(std::ffi::OsStr::new("src-tauri")) {
+      return Err(anyhow::anyhow!(
+            "The configured distDir is the `src-tauri` folder.
+            Please isolate your web assets on a separate folder and update `tauri.conf.json > build > distDir`.",
+          ));
+    }
+
+    let mut out_folders = Vec::new();
+    for folder in &["node_modules", "src-tauri", "target"] {
+      if web_asset_path.join(folder).is_dir() {
+        out_folders.push(folder.to_string());
+      }
+    }
+    if !out_folders.is_empty() {
+      return Err(anyhow::anyhow!(
+            "The configured distDir includes the `{:?}` {}. Please isolate your web assets on a separate folder and update `tauri.conf.json > build > distDir`.",
+            out_folders,
+            if out_folders.len() == 1 { "folder" }else { "folders" }
+          )
+        );
+    }
+  }
+
+  if options.runner.is_none() {
+    options.runner = config_.build.runner.clone();
+  }
+
+  if let Some(list) = options.features.as_mut() {
+    list.extend(config_.build.features.clone().unwrap_or_default());
+  }
+
+  Ok(interface)
+}
+
 fn run_hook(name: &str, hook: HookCommand, debug: bool) -> Result<()> {
   let (script, script_cwd) = match hook {
     HookCommand::Script(s) if s.is_empty() => (None, None),

+ 186 - 8
tooling/cli/src/mobile/android.rs

@@ -4,7 +4,7 @@
 
 use cargo_mobile::{
   android::{
-    adb,
+    aab, adb, apk,
     config::{Config as AndroidConfig, Metadata as AndroidMetadata},
     device::{Device, RunError},
     env::{Env, Error as EnvError},
@@ -14,7 +14,7 @@ use cargo_mobile::{
   device::PromptError,
   opts::{NoiseLevel, Profile},
   os,
-  target::call_for_targets_with_fallback,
+  target::{call_for_targets_with_fallback, TargetTrait},
   util::prompt,
 };
 use clap::{Parser, Subcommand};
@@ -30,6 +30,8 @@ use crate::{
   Result,
 };
 
+use std::{fmt::Write, path::PathBuf};
+
 pub(crate) mod project;
 
 #[derive(Debug, thiserror::Error)]
@@ -46,8 +48,10 @@ enum Error {
   OpenFailed(os::OpenFileError),
   #[error("{0}")]
   DevFailed(String),
+  #[error("{0}")]
+  BuildFailed(String),
   #[error(transparent)]
-  BuildFailed(BuildError),
+  AndroidStudioScriptFailed(BuildError),
   #[error(transparent)]
   RunFailed(RunError),
   #[error("{0}")]
@@ -77,7 +81,8 @@ pub struct AndroidStudioScriptOptions {
     long = "target",
     multiple_occurrences(true),
     multiple_values(true),
-    value_parser(clap::builder::PossibleValuesParser::new(["aarch64", "armv7", "i686", "x86_64"]))
+    default_value = Target::DEFAULT_KEY,
+    value_parser(clap::builder::PossibleValuesParser::new(Target::name_list()))
   )]
   targets: Option<Vec<String>>,
   /// Builds with the release flag
@@ -120,12 +125,59 @@ impl From<DevOptions> for crate::dev::Options {
   }
 }
 
+#[derive(Debug, Clone, Parser)]
+#[clap(about = "Android build")]
+pub struct BuildOptions {
+  /// Builds with the debug flag
+  #[clap(short, long)]
+  pub debug: bool,
+  /// Which targets to build (all by default).
+  #[clap(
+    short,
+    long = "target",
+    multiple_occurrences(true),
+    multiple_values(true),
+    value_parser(clap::builder::PossibleValuesParser::new(Target::name_list()))
+  )]
+  targets: Option<Vec<String>>,
+  /// List of cargo features to activate
+  #[clap(short, long, multiple_occurrences(true), multiple_values(true))]
+  pub features: Option<Vec<String>>,
+  /// JSON string or path to JSON file to merge with tauri.conf.json
+  #[clap(short, long)]
+  pub config: Option<String>,
+  /// Whether to split the APKs and AABs per ABIs.
+  #[clap(long)]
+  pub split_per_abi: bool,
+  /// Build APKs.
+  #[clap(long)]
+  pub apk: bool,
+  /// Build AABs.
+  #[clap(long)]
+  pub aab: bool,
+}
+
+impl From<BuildOptions> for crate::build::Options {
+  fn from(options: BuildOptions) -> Self {
+    Self {
+      runner: None,
+      debug: options.debug,
+      target: None,
+      features: options.features,
+      bundles: None,
+      config: options.config,
+      args: Vec::new(),
+    }
+  }
+}
+
 #[derive(Subcommand)]
 enum Commands {
   Init(InitOptions),
   /// Open project in Android Studio
   Open,
   Dev(DevOptions),
+  Build(BuildOptions),
   #[clap(hide(true))]
   AndroidStudioScript(AndroidStudioScriptOptions),
 }
@@ -134,8 +186,9 @@ pub fn command(cli: Cli) -> Result<()> {
   match cli.command {
     Commands::Init(options) => init_command(options, MobileTarget::Android)?,
     Commands::Open => open()?,
-    Commands::AndroidStudioScript(options) => android_studio_script(options)?,
     Commands::Dev(options) => dev(options)?,
+    Commands::Build(options) => build(options)?,
+    Commands::AndroidStudioScript(options) => android_studio_script(options)?,
   }
 
   Ok(())
@@ -182,8 +235,134 @@ fn device_prompt<'a>(env: &'_ Env) -> Result<Device<'a>, PromptError<adb::device
   }
 }
 
+fn get_targets_or_all<'a>(targets: Vec<String>) -> Result<Vec<&'a Target<'a>>, Error> {
+  if targets.is_empty() {
+    Ok(Target::all().iter().map(|t| t.1).collect())
+  } else {
+    let mut outs = Vec::new();
+
+    let possible_targets = Target::all()
+      .keys()
+      .map(|key| key.to_string())
+      .collect::<Vec<String>>()
+      .join(",");
+
+    for t in targets {
+      let target = Target::for_name(&t).ok_or_else(|| {
+        Error::TargetInvalid(format!(
+          "Target {} is invalid; the possible targets are {}",
+          t, possible_targets
+        ))
+      })?;
+      outs.push(target);
+    }
+    Ok(outs)
+  }
+}
+
+fn build(options: BuildOptions) -> Result<()> {
+  with_config(|root_conf, config, _metadata| {
+    ensure_init(config.project_dir(), MobileTarget::Android)
+      .map_err(|e| Error::ProjectNotInitialized(e.to_string()))?;
+
+    let env = Env::new().map_err(Error::EnvInitFailed)?;
+    super::init::init_dot_cargo(root_conf, Some(&env)).map_err(Error::InitDotCargo)?;
+
+    run_build(options, config, env).map_err(|e| Error::BuildFailed(e.to_string()))
+  })
+  .map_err(Into::into)
+}
+
+fn run_build(mut options: BuildOptions, config: &AndroidConfig, env: Env) -> Result<()> {
+  let profile = if options.debug {
+    Profile::Debug
+  } else {
+    Profile::Release
+  };
+
+  if !(options.apk || options.aab) {
+    // if the user didn't specify the format to build, we'll do both
+    options.apk = true;
+    options.aab = true;
+  }
+
+  let bundle_identifier = {
+    let tauri_config = get_tauri_config(None)?;
+    let tauri_config_guard = tauri_config.lock().unwrap();
+    let tauri_config_ = tauri_config_guard.as_ref().unwrap();
+    tauri_config_.tauri.bundle.identifier.clone()
+  };
+
+  let mut build_options = options.clone().into();
+  let interface = crate::build::setup(&mut build_options)?;
+
+  let app_settings = interface.app_settings();
+  let bin_path = app_settings.app_binary_path(&InterfaceOptions {
+    debug: build_options.debug,
+    ..Default::default()
+  })?;
+  let out_dir = bin_path.parent().unwrap();
+  let _lock = flock::open_rw(&out_dir.join("lock").with_extension("android"), "Android")?;
+
+  let cli_options = CliOptions {
+    features: build_options.features.clone(),
+    args: build_options.args.clone(),
+    vars: Default::default(),
+  };
+  write_options(cli_options, &bundle_identifier, MobileTarget::Android)?;
+
+  options
+    .features
+    .get_or_insert(Vec::new())
+    .push("custom-protocol".into());
+
+  let apk_outputs = if options.apk {
+    apk::build(
+      config,
+      &env,
+      NoiseLevel::Polite,
+      profile,
+      get_targets_or_all(Vec::new())?,
+      options.split_per_abi,
+    )?
+  } else {
+    Vec::new()
+  };
+
+  let aab_outputs = if options.aab {
+    aab::build(
+      config,
+      &env,
+      NoiseLevel::Polite,
+      profile,
+      get_targets_or_all(Vec::new())?,
+      options.split_per_abi,
+    )?
+  } else {
+    Vec::new()
+  };
+
+  log_finished(apk_outputs, "APK");
+  log_finished(aab_outputs, "AAB");
+
+  Ok(())
+}
+
+fn log_finished(outputs: Vec<PathBuf>, kind: &str) {
+  if !outputs.is_empty() {
+    let mut printable_paths = String::new();
+    for path in &outputs {
+      writeln!(printable_paths, "        {}", path.display()).unwrap();
+    }
+
+    log::info!(action = "Finished"; "{} {}{} at:\n{}", outputs.len(), kind, if outputs.len() == 1 { "" } else { "s" }, printable_paths);
+  }
+}
+
 fn dev(options: DevOptions) -> Result<()> {
   with_config(|_, config, _metadata| {
+    ensure_init(config.project_dir(), MobileTarget::Android)
+      .map_err(|e| Error::ProjectNotInitialized(e.to_string()))?;
     run_dev(options, config).map_err(|e| Error::DevFailed(e.to_string()))
   })
   .map_err(Into::into)
@@ -194,8 +373,7 @@ fn run_dev(options: DevOptions, config: &AndroidConfig) -> Result<()> {
   let mut interface = crate::dev::setup(&mut dev_options)?;
 
   let bundle_identifier = {
-    let tauri_config =
-      get_tauri_config(None).map_err(|e| Error::InvalidTauriConfig(e.to_string()))?;
+    let tauri_config = get_tauri_config(None)?;
     let tauri_config_guard = tauri_config.lock().unwrap();
     let tauri_config_ = tauri_config_guard.as_ref().unwrap();
     tauri_config_.tauri.bundle.identifier.clone()
@@ -319,7 +497,7 @@ fn android_studio_script(options: AndroidStudioScriptOptions) -> Result<()> {
       |target: &Target| {
         target
           .build(config, metadata, &env, NoiseLevel::Polite, true, profile)
-          .map_err(Error::BuildFailed)
+          .map_err(Error::AndroidStudioScriptFailed)
       },
     )
     .map_err(|e| Error::TargetInvalid(e.to_string()))?

+ 3 - 1
tooling/cli/templates/mobile/android/app/build.gradle.kts

@@ -41,9 +41,11 @@ android {
     flavorDimensions.add("abi")
     productFlavors {
         create("universal") {
+            val abiList = findProperty("abiList") as? String
+
             dimension = "abi"
             ndk {
-                abiFilters += listOf(
+                abiFilters += abiList?.split(",")?.map { it.trim() } ?: listOf(
                     {{~#each targets}}
                     "{{this.abi}}",{{/each}}
                 )