tauri-cli 2.10.1

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::{
  device_prompt, ensure_init, env, get_app, get_config, inject_resources, load_pbxproj,
  open_and_wait, synchronize_project_config, MobileTarget, ProjectConfig,
};
use crate::{
  dev::Options as DevOptions,
  error::{Context, ErrorExt},
  helpers::{
    app_paths::Dirs,
    config::{get_config as get_tauri_config, ConfigMetadata},
    flock,
    plist::merge_plist,
  },
  interface::{AppInterface, MobileOptions, Options as InterfaceOptions},
  mobile::{
    ios::ensure_ios_runtime_installed, use_network_address_for_dev_url, write_options, CliOptions,
    DevChild, DevHost, DevProcess,
  },
  ConfigValue, Result,
};
use clap::{ArgAction, Parser};

use cargo_mobile2::{
  apple::{
    config::Config as AppleConfig,
    device::{Device, DeviceKind, RunError},
    target::BuildError,
  },
  env::Env,
  opts::{NoiseLevel, Profile},
};
use url::Host;

use std::{env::set_current_dir, net::Ipv4Addr, path::PathBuf};

const PHYSICAL_IPHONE_DEV_WARNING: &str = "To develop on physical phones you need the `--host` option (not required for Simulators). See the documentation for more information: https://v2.tauri.app/develop/#development-server";

#[derive(Debug, Clone, Parser)]
#[clap(
  about = "Run your app in development mode on iOS",
  long_about = "Run your app in development mode on iOS with hot-reloading for the Rust code.
It makes use of the `build.devUrl` property from your `tauri.conf.json` file.
It also runs your `build.beforeDevCommand` which usually starts your frontend devServer.

When connected to a physical iOS device, the public network address must be used instead of `localhost`
for the devUrl property. Tauri makes that change automatically, but your dev server might need
a different configuration to listen on the public address. You can check the `TAURI_DEV_HOST`
environment variable to determine whether the public network should be used or not."
)]
pub struct Options {
  /// List of cargo features to activate
  #[clap(short, long, action = ArgAction::Append, num_args(0..), value_delimiter = ',')]
  pub features: Vec<String>,
  /// Exit on panic
  #[clap(short, long)]
  exit_on_panic: bool,
  /// JSON strings or paths to JSON, JSON5 or TOML files to merge with the default configuration file
  ///
  /// Configurations are merged in the order they are provided, which means a particular value overwrites previous values when a config key-value pair conflicts.
  ///
  /// Note that a platform-specific file is looked up and merged with the default file by default
  /// (tauri.macos.conf.json, tauri.linux.conf.json, tauri.windows.conf.json, tauri.android.conf.json and tauri.ios.conf.json)
  /// but you can use this for more specific use cases such as different build flavors.
  #[clap(short, long)]
  pub config: Vec<ConfigValue>,
  /// Run the code in release mode
  #[clap(long = "release")]
  pub release_mode: bool,
  /// Skip waiting for the frontend dev server to start before building the tauri application.
  #[clap(long, env = "TAURI_CLI_NO_DEV_SERVER_WAIT")]
  pub no_dev_server_wait: bool,
  /// Disable the file watcher
  #[clap(long)]
  pub no_watch: bool,
  /// Additional paths to watch for changes.
  #[clap(long)]
  pub additional_watch_folders: Vec<PathBuf>,
  /// Open Xcode instead of trying to run on a connected device
  #[clap(short, long)]
  pub open: bool,
  /// Runs on the given device name
  pub device: Option<String>,
  /// Force prompting for an IP to use to connect to the dev server on mobile.
  #[clap(long)]
  pub force_ip_prompt: bool,
  /// Use the public network address for the development server.
  /// If an actual address it provided, it is used instead of prompting to pick one.
  ///
  /// This option is particularly useful along the `--open` flag when you intend on running on a physical device.
  ///
  /// This replaces the devUrl configuration value to match the public network address host,
  /// it is your responsibility to set up your development server to listen on this address
  /// by using 0.0.0.0 as host for instance.
  ///
  /// When this is set or when running on an iOS device the CLI sets the `TAURI_DEV_HOST`
  /// environment variable so you can check this on your framework's configuration to expose the development server
  /// on the public network address.
  #[clap(long, default_value_t, default_missing_value(""), num_args(0..=1))]
  pub host: DevHost,
  /// Disable the built-in dev server for static files.
  #[clap(long)]
  pub no_dev_server: bool,
  /// Specify port for the built-in dev server for static files. Defaults to 1430.
  #[clap(long, env = "TAURI_CLI_PORT")]
  pub port: Option<u16>,
  /// Command line arguments passed to the runner.
  /// Use `--` to explicitly mark the start of the arguments.
  /// e.g. `tauri ios dev -- [runnerArgs]`.
  #[clap(last(true))]
  pub args: Vec<String>,
  /// Path to the certificate file used by your dev server. Required for mobile dev when using HTTPS.
  #[clap(long, env = "TAURI_DEV_ROOT_CERTIFICATE_PATH")]
  pub root_certificate_path: Option<PathBuf>,
}

impl From<Options> for DevOptions {
  fn from(options: Options) -> Self {
    Self {
      runner: None,
      target: None,
      features: options.features,
      exit_on_panic: options.exit_on_panic,
      config: options.config,
      release_mode: options.release_mode,
      args: options.args,
      no_watch: options.no_watch,
      additional_watch_folders: options.additional_watch_folders,
      no_dev_server: options.no_dev_server,
      no_dev_server_wait: options.no_dev_server_wait,
      port: options.port,
      host: options.host.0.unwrap_or_default(),
    }
  }
}

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

  let result = run_command(options, noise_level, dirs);
  if result.is_err() {
    crate::dev::kill_before_dev_process();
  }
  result
}

fn run_command(options: Options, noise_level: NoiseLevel, dirs: Dirs) -> Result<()> {
  // setup env additions before calling env()
  if let Some(root_certificate_path) = &options.root_certificate_path {
    std::env::set_var(
      "TAURI_DEV_ROOT_CERTIFICATE",
      std::fs::read_to_string(root_certificate_path).fs_context(
        "failed to read root certificate file",
        root_certificate_path.clone(),
      )?,
    );
  }

  let env = env().context("failed to load iOS environment")?;
  let device = if options.open {
    None
  } else {
    match device_prompt(&env, options.device.as_deref()) {
      Ok(d) => Some(d),
      Err(e) => {
        log::error!("{e}");
        None
      }
    }
  };

  if device.is_some() {
    ensure_ios_runtime_installed()?;
  }

  let mut dev_options: DevOptions = options.clone().into();
  let target_triple = device
    .as_ref()
    .map(|d| d.target().triple.to_string())
    .unwrap_or_else(|| "aarch64-apple-ios".into());
  dev_options.target = Some(target_triple.clone());
  dev_options.args.push("--lib".into());

  let tauri_config = get_tauri_config(
    tauri_utils::platform::Target::Ios,
    &options.config.iter().map(|c| &c.0).collect::<Vec<_>>(),
    dirs.tauri,
  )?;
  let interface = AppInterface::new(&tauri_config, Some(target_triple), dirs.tauri)?;

  let app = get_app(MobileTarget::Ios, &tauri_config, &interface, dirs.tauri);
  let (config, _) = get_config(
    &app,
    &tauri_config,
    &dev_options.features,
    &CliOptions {
      dev: true,
      features: dev_options.features.clone(),
      args: dev_options.args.clone(),
      noise_level,
      vars: Default::default(),
      config: dev_options.config.clone(),
      target_device: None,
    },
    dirs.tauri,
  )?;

  set_current_dir(dirs.tauri).context("failed to set current directory to Tauri directory")?;

  ensure_init(
    &tauri_config,
    config.app(),
    config.project_dir(),
    MobileTarget::Ios,
    false,
  )?;
  inject_resources(&config, &tauri_config)?;

  let info_plist_path = config
    .project_dir()
    .join(config.scheme())
    .join("Info.plist");
  let mut src_plists = vec![info_plist_path.clone().into()];
  if dirs.tauri.join("Info.plist").exists() {
    src_plists.push(dirs.tauri.join("Info.plist").into());
  }
  if dirs.tauri.join("Info.ios.plist").exists() {
    src_plists.push(dirs.tauri.join("Info.ios.plist").into());
  }
  if let Some(info_plist) = &tauri_config.bundle.ios.info_plist {
    src_plists.push(info_plist.clone().into());
  }
  let merged_info_plist = merge_plist(src_plists)?;
  merged_info_plist
    .to_file_xml(&info_plist_path)
    .map_err(std::io::Error::other)
    .fs_context("failed to save merged Info.plist file", info_plist_path)?;

  let mut pbxproj = load_pbxproj(&config)?;

  // synchronize pbxproj
  synchronize_project_config(
    &config,
    &tauri_config,
    &mut pbxproj,
    &mut plist::Dictionary::new(),
    &ProjectConfig {
      code_sign_identity: None,
      team_id: None,
      provisioning_profile_uuid: None,
    },
    !options.release_mode,
  )?;
  if pbxproj.has_changes() {
    pbxproj
      .save()
      .fs_context("failed to save pbxproj file", pbxproj.path)?;
  }

  run_dev(
    interface,
    options,
    dev_options,
    tauri_config,
    device,
    env,
    &config,
    noise_level,
    &dirs,
  )
}

#[allow(clippy::too_many_arguments)]
fn run_dev(
  mut interface: AppInterface,
  options: Options,
  mut dev_options: DevOptions,
  mut tauri_config: ConfigMetadata,
  device: Option<Device>,
  env: Env,
  config: &AppleConfig,
  noise_level: NoiseLevel,
  dirs: &Dirs,
) -> Result<()> {
  // when --host is provided or running on a physical device or resolving 0.0.0.0 we must use the network IP
  if options.host.0.is_some()
    || device
      .as_ref()
      .map(|device| !matches!(device.kind(), DeviceKind::Simulator))
      .unwrap_or(false)
    || tauri_config.build.dev_url.as_ref().is_some_and(|url| {
      matches!(
          url.host(),
          Some(Host::Ipv4(i)) if i == Ipv4Addr::UNSPECIFIED
      )
    })
  {
    use_network_address_for_dev_url(
      &mut tauri_config,
      &mut dev_options,
      options.force_ip_prompt,
      dirs.tauri,
    )?;
  }

  crate::dev::setup(&interface, &mut dev_options, &mut tauri_config, dirs)?;

  let app_settings = interface.app_settings();
  let out_dir = app_settings.out_dir(
    &InterfaceOptions {
      debug: !dev_options.release_mode,
      target: dev_options.target.clone(),
      ..Default::default()
    },
    dirs.tauri,
  )?;
  let _lock = flock::open_rw(out_dir.join("lock").with_extension("ios"), "iOS")?;

  let set_host = options.host.0.is_some();

  let open = options.open;
  interface.mobile_dev(
    &mut tauri_config,
    MobileOptions {
      debug: true,
      features: options.features,
      args: options.args,
      config: dev_options.config.clone(),
      no_watch: options.no_watch,
      additional_watch_folders: options.additional_watch_folders,
    },
    |options, tauri_config| {
      let cli_options = CliOptions {
        dev: true,
        features: options.features.clone(),
        args: options.args.clone(),
        noise_level,
        vars: Default::default(),
        config: dev_options.config.clone(),
        target_device: None,
      };
      let _handle = write_options(tauri_config, cli_options)?;

      let open_xcode = || {
        if !set_host {
          log::warn!("{PHYSICAL_IPHONE_DEV_WARNING}");
        }
        open_and_wait(config, &env)
      };

      if open {
        open_xcode()
      } else if let Some(device) = &device {
        match run(device, options, config, noise_level, &env) {
          Ok(c) => Ok(Box::new(c) as Box<dyn DevProcess + Send>),
          Err(RunError::BuildFailed(BuildError::Sdk(sdk_err))) => {
            log::warn!("{sdk_err}");
            open_xcode()
          }
          Err(e) => {
            crate::dev::kill_before_dev_process();
            crate::error::bail!("failed to run iOS app: {}", e)
          }
        }
      } else {
        open_xcode()
      }
    },
    dirs,
  )
}

fn run(
  device: &Device<'_>,
  options: MobileOptions,
  config: &AppleConfig,
  noise_level: NoiseLevel,
  env: &Env,
) -> std::result::Result<DevChild, RunError> {
  let profile = if options.debug {
    Profile::Debug
  } else {
    Profile::Release
  };

  device
    .run(
      config,
      env,
      noise_level,
      false, // do not quit on app exit
      profile,
    )
    .map(DevChild::new)
}