tauri-cli 3.0.0-alpha.3

Command line interface for building Tauri apps
Documentation
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT

use super::{MobileTarget, detect_target_ok, ensure_init, env, get_app, get_config, read_options};
use crate::{
  Error, Result,
  error::{Context, ErrorExt},
  helpers::config::{get_config as get_tauri_config, reload_config as reload_tauri_config},
  interface::AppInterface,
  mobile::CliOptions,
};
use clap::{ArgAction, Parser};

use cargo_mobile2::{
  android::{adb, device::ConnectionStatus, target::Target},
  opts::Profile,
  target::{TargetTrait, call_for_targets_with_fallback},
};

use std::path::Path;

#[derive(Debug, Parser)]
pub struct Options {
  /// Targets to build.
  #[clap(
    short,
    long = "target",
    action = ArgAction::Append,
    num_args(0..),
    default_value = Target::DEFAULT_KEY,
    value_parser(clap::builder::PossibleValuesParser::new(Target::name_list()))
  )]
  targets: Option<Vec<String>>,
  /// Builds with the release flag
  #[clap(short, long)]
  release: bool,
}

pub fn command(options: Options) -> Result<()> {
  let dirs = crate::helpers::app_paths::resolve_dirs();

  let profile = if options.release {
    Profile::Release
  } else {
    Profile::Debug
  };

  let mut tauri_config = get_tauri_config(tauri_utils::platform::Target::Android, &[], dirs.tauri)?;
  let cli_options = read_options(MobileTarget::Android, dirs.tauri)?;

  if !cli_options.config.is_empty() {
    // reload config with merges from the android dev|build script
    reload_tauri_config(
      &mut tauri_config,
      &cli_options
        .config
        .iter()
        .map(|conf| &conf.0)
        .collect::<Vec<_>>(),
      dirs.tauri,
    )?
  };

  let (config, metadata) = get_config(
    &get_app(
      MobileTarget::Android,
      &tauri_config,
      &AppInterface::new(&tauri_config, None, dirs.tauri)?,
      dirs.tauri,
    ),
    &tauri_config,
    &[],
    &cli_options,
  );

  ensure_init(
    &tauri_config,
    config.app(),
    config.project_dir(),
    MobileTarget::Android,
    std::env::var("CI").is_ok(),
  )?;

  if !cli_options.config.is_empty() {
    crate::helpers::config::merge_config_with(
      &mut tauri_config,
      &cli_options
        .config
        .iter()
        .map(|conf| &conf.0)
        .collect::<Vec<_>>(),
    )?;
  }

  let env = env(std::env::var("CI").is_ok())?;

  if cli_options.dev
    && let Some(url) = &tauri_config.build.dev_url
  {
    let localhost = match url.host() {
      Some(url::Host::Domain(d)) => d == "localhost",
      Some(url::Host::Ipv4(i)) => i == std::net::Ipv4Addr::LOCALHOST,
      _ => false,
    };

    if localhost && let Some(port) = url.port_or_known_default() {
      adb_forward_port(port, &env, &cli_options)?;
    }
  }

  let mut validated_lib = false;

  let installed_targets =
    crate::interface::rust::installation::installed_targets().unwrap_or_default();

  call_for_targets_with_fallback(
    options.targets.unwrap_or_default().iter(),
    &detect_target_ok,
    &env,
    |target: &Target| {
      if !installed_targets.contains(&target.triple().into()) {
        log::info!("Installing target {}", target.triple());
        target
          .install()
          .map_err(|error| Error::CommandFailed {
            command: "rustup target add".to_string(),
            error,
          })
          .context("failed to install target")?;
      }

      target
        .build(
          &config,
          &metadata,
          &env,
          cli_options.noise_level,
          true,
          profile,
        )
        .context("failed to build Android app")?;

      if !validated_lib {
        validated_lib = true;

        let lib_path = config
          .app()
          .target_dir(target.triple, profile)
          .join(config.so_name());

        validate_lib(&lib_path).context("failed to validate library")?;
      }

      Ok(())
    },
  )
  .map_err(|e| Error::GenericError(e.to_string()))?
}

fn validate_lib(path: &Path) -> Result<()> {
  let so_bytes = std::fs::read(path).fs_context("failed to read library", path.to_path_buf())?;
  let elf = elf::ElfBytes::<elf::endian::AnyEndian>::minimal_parse(&so_bytes)
    .context("failed to parse ELF")?;
  let (symbol_table, string_table) = elf
    .dynamic_symbol_table()
    .context("failed to read dynsym section")?
    .context("missing dynsym tables")?;

  let mut symbols = Vec::new();
  for s in symbol_table.iter() {
    if let Ok(symbol) = string_table.get(s.st_name as usize) {
      symbols.push(symbol);
    }
  }

  if !symbols.contains(&"Java_app_tauri_plugin_PluginManager_handlePluginResponse") {
    crate::error::bail!(
      "Library from {} does not include required runtime symbols. This means you are likely missing the tauri::mobile_entry_point macro usage, see the documentation for more information: https://v2.tauri.app/start/migrate/from-tauri-1",
      path.display()
    );
  }

  Ok(())
}

fn adb_forward_port(
  port: u16,
  env: &cargo_mobile2::android::env::Env,
  cli_options: &CliOptions,
) -> Result<()> {
  let forward = format!("tcp:{port}");
  log::info!("Forwarding port {port} with adb");

  let mut devices = adb::device_list(env)
    .unwrap_or_default()
    .into_iter()
    .filter(|d| d.status() == ConnectionStatus::Connected)
    .collect::<Vec<_>>();
  // if we could not detect any running device, let's wait a few seconds, it might be booting up
  if devices.is_empty() {
    log::warn!(
      "ADB device list is empty, waiting a few seconds to see if there's any booting device..."
    );

    let max = 5;
    let mut count = 0;
    loop {
      std::thread::sleep(std::time::Duration::from_secs(1));

      devices = adb::device_list(env)
        .unwrap_or_default()
        .into_iter()
        .filter(|d| d.status() == ConnectionStatus::Connected)
        .collect::<Vec<_>>();
      if !devices.is_empty() {
        break;
      }

      count += 1;
      if count == max {
        break;
      }
    }
  }

  let target_device = if let Some(target_device) = &cli_options.target_device {
    Some((target_device.id.clone(), target_device.name.clone()))
  } else if devices.len() == 1 {
    let device = devices.first().unwrap();
    Some((device.serial_no().to_string(), device.name().to_string()))
  } else if devices.len() > 1 {
    crate::error::bail!(
      "Multiple Android devices are connected ({}), please disconnect devices you do not intend to use so Tauri can determine which to use",
      devices
        .iter()
        .map(|d| d.name())
        .collect::<Vec<_>>()
        .join(", ")
    );
  } else {
    // when building the app without running to a device, we might have an empty devices list
    None
  };

  if let Some((target_device_serial_no, target_device_name)) = target_device {
    let mut already_forwarded = false;

    // clear port forwarding for all devices other than the target
    for device in &devices {
      let reverse_list_output =
        adb_reverse_list(env, device.serial_no()).map_err(|error| Error::CommandFailed {
          command: "adb reverse --list".to_string(),
          error,
        })?;

      // check if the device has the port forwarded
      if !reverse_list_has_forward(
        &String::from_utf8_lossy(&reverse_list_output.stdout),
        &forward,
      ) {
        continue;
      }

      if device.serial_no() == target_device_serial_no {
        // device matches our target, we can skip forwarding
        log::debug!(
          "device {} already has the forward for {}",
          device.name(),
          forward
        );
        already_forwarded = true;
      } else if let Err(error) = adb_reverse_remove(env, device.serial_no(), &forward) {
        log::warn!(
          "failed to remove the {forward} forward from device {}: {error}",
          device.name()
        );
      }
    }

    // if there's a known target, we should forward the port to it
    if already_forwarded {
      log::info!("{forward} already forwarded to {target_device_name}");
    } else {
      const MAX_ATTEMPTS: u32 = 5;
      let mut attempt = 0;
      loop {
        attempt += 1;
        run_adb_reverse(env, &target_device_serial_no, &forward, &forward).map_err(|error| {
          Error::CommandFailed {
            command: format!("adb reverse {forward} {forward}"),
            error,
          }
        })?;

        let reverse_list_output =
          adb_reverse_list(env, &target_device_serial_no).map_err(|error| {
            Error::CommandFailed {
              command: "adb reverse --list".to_string(),
              error,
            }
          })?;
        // wait and retry until the port has actually been forwarded
        if reverse_list_has_forward(
          &String::from_utf8_lossy(&reverse_list_output.stdout),
          &forward,
        ) {
          break;
        } else if attempt >= MAX_ATTEMPTS {
          log::warn!(
            "could not verify that {forward} was forwarded to {target_device_name} after {MAX_ATTEMPTS} attempts; the app might not be able to reach the dev server"
          );
          break;
        } else {
          log::warn!(
            "waiting for the port to be forwarded to {}...",
            target_device_name
          );
          std::thread::sleep(std::time::Duration::from_secs(1));
        }
      }
    }
  } else {
    log::warn!("no running devices detected with ADB; skipping port forwarding");
  }

  Ok(())
}

fn run_adb_reverse(
  env: &cargo_mobile2::android::env::Env,
  device_serial_no: &str,
  remote: &str,
  local: &str,
) -> std::io::Result<std::process::Output> {
  adb::adb(env, ["-s", device_serial_no, "reverse", remote, local])
    .stdin_file(os_pipe::dup_stdin().unwrap())
    .stdout_file(os_pipe::dup_stdout().unwrap())
    .stderr_file(os_pipe::dup_stdout().unwrap())
    .run()
}

fn adb_reverse_list(
  env: &cargo_mobile2::android::env::Env,
  device_serial_no: &str,
) -> std::io::Result<std::process::Output> {
  adb::adb(env, ["-s", device_serial_no, "reverse", "--list"])
    .stdin_file(os_pipe::dup_stdin().unwrap())
    .stdout_capture()
    .stderr_capture()
    .run()
}

fn adb_reverse_remove(
  env: &cargo_mobile2::android::env::Env,
  device_serial_no: &str,
  remote: &str,
) -> std::io::Result<std::process::Output> {
  adb::adb(env, ["-s", device_serial_no, "reverse", "--remove", remote])
    .stdin_file(os_pipe::dup_stdin().unwrap())
    .stdout_capture()
    .stderr_capture()
    .run()
}

/// Checks whether the output of `adb reverse --list` contains the given `tcp:<port>` forward.
///
/// Each line has the format `<serial> <remote> <local>`, so we compare the remote column exactly
/// to avoid e.g. `tcp:80` matching `tcp:8080`.
fn reverse_list_has_forward(output: &str, forward: &str) -> bool {
  output
    .lines()
    .any(|line| line.split_whitespace().nth(1) == Some(forward))
}

#[cfg(test)]
mod tests {
  use super::reverse_list_has_forward;

  #[test]
  fn reverse_list_matches_exact_port() {
    let output = "emulator-5554 tcp:8080 tcp:8080\nemulator-5554 tcp:1420 tcp:1420\n";
    assert!(reverse_list_has_forward(output, "tcp:8080"));
    assert!(reverse_list_has_forward(output, "tcp:1420"));
    assert!(!reverse_list_has_forward(output, "tcp:80"));
    assert!(!reverse_list_has_forward(output, "tcp:142"));
    assert!(!reverse_list_has_forward("", "tcp:1420"));
  }

  #[test]
  fn reverse_list_ignores_local_column() {
    let output = "UsbFfs tcp:5173 tcp:1420\n";
    assert!(reverse_list_has_forward(output, "tcp:5173"));
    assert!(!reverse_list_has_forward(output, "tcp:1420"));
  }
}