Browse Source

refactor(cli): set binary name on dev (#4447)

Lucas Fernandes Nogueira 3 năm trước cách đây
mục cha
commit
b025b9f581

+ 6 - 0
.changes/dev-bin-name.md

@@ -0,0 +1,6 @@
+---
+"cli.rs": patch
+"cli.js": patch
+---
+
+Set the binary name to the product name in development.

+ 1 - 0
examples/api/src-tauri/Cargo.lock

@@ -3233,6 +3233,7 @@ dependencies = [
  "sha2",
  "tauri-utils",
  "thiserror",
+ "time",
  "uuid 1.1.2",
  "walkdir",
 ]

+ 38 - 133
tooling/cli/src/build.rs

@@ -2,57 +2,59 @@
 // SPDX-License-Identifier: Apache-2.0
 // SPDX-License-Identifier: MIT
 
-use crate::helpers::{
-  app_paths::{app_dir, tauri_dir},
-  command_env,
-  config::{get as get_config, AppUrl, WindowUrl},
-  manifest::rewrite_manifest,
-  updater_signature::sign_file_from_env_variables,
+use crate::{
+  helpers::{
+    app_paths::{app_dir, tauri_dir},
+    command_env,
+    config::{get as get_config, AppUrl, WindowUrl},
+    manifest::rewrite_manifest,
+    updater_signature::sign_file_from_env_variables,
+  },
+  interface::{AppInterface, AppSettings, Interface},
+  CommandExt, Result,
 };
-use crate::{CommandExt, Result};
 use anyhow::{bail, Context};
 use clap::Parser;
-#[cfg(target_os = "linux")]
-use heck::ToKebabCase;
 use log::warn;
 use log::{error, info};
-use std::{env::set_current_dir, fs::rename, path::PathBuf, process::Command};
+use std::{env::set_current_dir, path::PathBuf, process::Command};
 use tauri_bundler::bundle::{bundle_project, PackageType};
 
-#[derive(Debug, Parser)]
+#[derive(Debug, Clone, Parser)]
 #[clap(about = "Tauri build")]
 pub struct Options {
   /// Binary to use to build the application, defaults to `cargo`
   #[clap(short, long)]
-  runner: Option<String>,
+  pub runner: Option<String>,
   /// Builds with the debug flag
   #[clap(short, long)]
-  debug: bool,
+  pub debug: bool,
   /// Target triple to build against.
   ///
   /// It must be one of the values outputted by `$rustc --print target-list` or `universal-apple-darwin` for an universal macOS application.
   ///
   /// Note that compiling an universal macOS application requires both `aarch64-apple-darwin` and `x86_64-apple-darwin` targets to be installed.
   #[clap(short, long)]
-  target: Option<String>,
+  pub target: Option<String>,
   /// Space or comma separated list of features to activate
   #[clap(short, long, multiple_occurrences(true), multiple_values(true))]
-  features: Option<Vec<String>>,
+  pub features: Option<Vec<String>>,
   /// Space or comma separated list of bundles to package.
   ///
   /// Each bundle must be one of `deb`, `appimage`, `msi`, `app` or `dmg` on MacOS and `updater` on all platforms.
+  /// If `none` is specified, the bundler will be skipped.
   ///
   /// Note that the `updater` bundle is not automatically added so you must specify it if the updater is enabled.
   #[clap(short, long, multiple_occurrences(true), multiple_values(true))]
-  bundles: Option<Vec<String>>,
+  pub bundles: Option<Vec<String>>,
   /// JSON string or path to JSON file to merge with tauri.conf.json
   #[clap(short, long)]
-  config: Option<String>,
+  pub config: Option<String>,
   /// Command line arguments passed to the runner
-  args: Vec<String>,
+  pub args: Vec<String>,
 }
 
-pub fn command(options: Options) -> Result<()> {
+pub fn command(mut options: Options) -> Result<()> {
   let merge_config = if let Some(config) = &options.config {
     Some(if config.starts_with('{') {
       config.to_string()
@@ -150,116 +152,30 @@ pub fn command(options: Options) -> Result<()> {
     }
   }
 
-  let runner_from_config = config_.build.runner.clone();
-  let runner = options
-    .runner
-    .or(runner_from_config)
-    .unwrap_or_else(|| "cargo".to_string());
-
-  let mut features = config_.build.features.clone().unwrap_or_default();
-  if let Some(list) = options.features {
-    features.extend(list);
-  }
-
-  let mut args = Vec::new();
-  if !options.args.is_empty() {
-    args.extend(options.args);
-  }
-
-  if !features.is_empty() {
-    args.push("--features".into());
-    args.push(features.join(","));
-  }
-
-  if !options.debug {
-    args.push("--release".into());
-  }
-
-  let app_settings = crate::interface::rust::AppSettings::new(config_)?;
-
-  let out_dir = app_settings
-    .get_out_dir(options.target.clone(), options.debug)
-    .with_context(|| "failed to get project out directory")?;
-
-  let bin_name = app_settings
-    .cargo_package_settings()
-    .name
-    .clone()
-    .expect("Cargo manifest must have the `package.name` field");
-
-  let target: String = if let Some(target) = options.target.clone() {
-    target
-  } else {
-    tauri_utils::platform::target_triple()?
-  };
-  let binary_extension: String = if target.contains("windows") {
-    "exe"
-  } else {
-    ""
+  if options.runner.is_none() {
+    options.runner = config_.build.runner.clone();
   }
-  .into();
-
-  let bin_path = out_dir.join(&bin_name).with_extension(&binary_extension);
-
-  let no_default_features = args.contains(&"--no-default-features".into());
-
-  if options.target == Some("universal-apple-darwin".into()) {
-    std::fs::create_dir_all(&out_dir).with_context(|| "failed to create project out directory")?;
-
-    let mut lipo_cmd = Command::new("lipo");
-    lipo_cmd
-      .arg("-create")
-      .arg("-output")
-      .arg(out_dir.join(&bin_name));
-    for triple in ["aarch64-apple-darwin", "x86_64-apple-darwin"] {
-      let mut args_ = args.clone();
-      args_.push("--target".into());
-      args_.push(triple.into());
-      crate::interface::rust::build_project(runner.clone(), args_)
-        .with_context(|| format!("failed to build {} binary", triple))?;
-      let triple_out_dir = app_settings
-        .get_out_dir(Some(triple.into()), options.debug)
-        .with_context(|| format!("failed to get {} out dir", triple))?;
-      lipo_cmd.arg(triple_out_dir.join(&bin_name));
-    }
 
-    let lipo_status = lipo_cmd.status()?;
-    if !lipo_status.success() {
-      return Err(anyhow::anyhow!(format!(
-        "Result of `lipo` command was unsuccessful: {}. (Is `lipo` installed?)",
-        lipo_status
-      )));
-    }
-  } else {
-    if let Some(target) = &options.target {
-      args.push("--target".into());
-      args.push(target.clone());
-    }
-    crate::interface::rust::build_project(runner, args).with_context(|| "failed to build app")?;
+  if let Some(list) = options.features.as_mut() {
+    list.extend(config_.build.features.clone().unwrap_or_default());
   }
 
-  if let Some(product_name) = config_.package.product_name.clone() {
-    #[cfg(target_os = "linux")]
-    let product_name = product_name.to_kebab_case();
+  let interface = AppInterface::new(config_)?;
+  let app_settings = interface.app_settings();
+  let interface_options = options.clone().into();
 
-    let product_path = out_dir
-      .join(&product_name)
-      .with_extension(&binary_extension);
+  let bin_path = app_settings.app_binary_path(&interface_options)?;
+  let out_dir = bin_path.parent().unwrap();
 
-    rename(&bin_path, &product_path).with_context(|| {
-      format!(
-        "failed to rename `{}` to `{}`",
-        bin_path.display(),
-        product_path.display(),
-      )
-    })?;
-  }
+  interface
+    .build(interface_options)
+    .with_context(|| "failed to build app")?;
 
   if config_.tauri.bundle.active {
-    let package_types = if let Some(names) = options.bundles {
+    let package_types = if let Some(names) = &options.bundles {
       let mut types = vec![];
       for name in names
-        .into_iter()
+        .iter()
         .flat_map(|n| n.split(',').map(|s| s.to_string()).collect::<Vec<String>>())
       {
         if name == "none" {
@@ -293,20 +209,9 @@ pub fn command(options: Options) -> Result<()> {
       }
     }
 
-    let mut enabled_features = features.clone();
-    if !no_default_features {
-      enabled_features.push("default".into());
-    }
-    let settings = crate::interface::get_bundler_settings(
-      app_settings,
-      target,
-      &enabled_features,
-      &manifest,
-      config_,
-      &out_dir,
-      package_types,
-    )
-    .with_context(|| "failed to build bundler settings")?;
+    let settings = app_settings
+      .get_bundler_settings(&options.into(), &manifest, config_, out_dir, package_types)
+      .with_context(|| "failed to build bundler settings")?;
 
     // set env vars used by the bundler
     #[cfg(target_os = "linux")]

+ 47 - 269
tooling/cli/src/dev.rs

@@ -9,6 +9,7 @@ use crate::{
     config::{get as get_config, reload as reload_config, AppUrl, ConfigHandle, WindowUrl},
     manifest::{rewrite_manifest, Manifest},
   },
+  interface::{AppInterface, DevProcess, ExitReason, Interface},
   Result,
 };
 use clap::Parser;
@@ -23,9 +24,9 @@ use std::{
   env::set_current_dir,
   ffi::OsStr,
   fs::FileType,
-  io::{BufReader, ErrorKind, Write},
+  io::Write,
   path::{Path, PathBuf},
-  process::{exit, Command, Stdio},
+  process::{exit, Command, ExitStatus, Stdio},
   sync::{
     atomic::{AtomicBool, Ordering},
     mpsc::channel,
@@ -42,18 +43,18 @@ const KILL_CHILDREN_SCRIPT: &[u8] = include_bytes!("../scripts/kill-children.sh"
 
 const TAURI_DEV_WATCHER_GITIGNORE: &[u8] = include_bytes!("../tauri-dev-watcher.gitignore");
 
-#[derive(Debug, Parser)]
+#[derive(Debug, Clone, Parser)]
 #[clap(about = "Tauri dev", trailing_var_arg(true))]
 pub struct Options {
   /// Binary to use to run the application
   #[clap(short, long)]
-  runner: Option<String>,
+  pub runner: Option<String>,
   /// Target triple to build against
   #[clap(short, long)]
-  target: Option<String>,
+  pub target: Option<String>,
   /// List of cargo features to activate
   #[clap(short, long, multiple_occurrences(true), multiple_values(true))]
-  features: Option<Vec<String>>,
+  pub features: Option<Vec<String>>,
   /// Exit on panic
   #[clap(short, long)]
   exit_on_panic: bool,
@@ -62,9 +63,9 @@ pub struct Options {
   config: Option<String>,
   /// Run the code in release mode
   #[clap(long = "release")]
-  release_mode: bool,
+  pub release_mode: bool,
   /// Command line arguments passed to the runner
-  args: Vec<String>,
+  pub args: Vec<String>,
 }
 
 pub fn command(options: Options) -> Result<()> {
@@ -77,7 +78,7 @@ pub fn command(options: Options) -> Result<()> {
   r
 }
 
-fn command_internal(options: Options) -> Result<()> {
+fn command_internal(mut options: Options) -> Result<()> {
   let tauri_path = tauri_dir();
   let merge_config = if let Some(config) = &options.config {
     Some(if config.starts_with('{') {
@@ -155,19 +156,16 @@ fn command_internal(options: Options) -> Result<()> {
     }
   }
 
-  let runner_from_config = config
-    .lock()
-    .unwrap()
-    .as_ref()
-    .unwrap()
-    .build
-    .runner
-    .clone();
-  let runner = options
-    .runner
-    .clone()
-    .or(runner_from_config)
-    .unwrap_or_else(|| "cargo".to_string());
+  if options.runner.is_none() {
+    options.runner = config
+      .lock()
+      .unwrap()
+      .as_ref()
+      .unwrap()
+      .build
+      .runner
+      .clone();
+  }
 
   let manifest = {
     let (tx, rx) = channel();
@@ -195,8 +193,6 @@ fn command_internal(options: Options) -> Result<()> {
     cargo_features.extend(features.clone());
   }
 
-  let manually_killed_app = Arc::new(AtomicBool::default());
-
   if std::env::var_os("TAURI_SKIP_DEVSERVER_CHECK") != Some("true".into()) {
     if let AppUrl::Url(WindowUrl::External(dev_server_url)) = config
       .lock()
@@ -256,24 +252,22 @@ fn command_internal(options: Options) -> Result<()> {
     }
   }
 
-  let process = start_app(
-    &options,
-    &runner,
-    &manifest,
-    &cargo_features,
-    manually_killed_app.clone(),
-  )?;
+  let interface = AppInterface::new(config.lock().unwrap().as_ref().unwrap())?;
+
+  let exit_on_panic = options.exit_on_panic;
+  let process = interface.dev(options.clone().into(), &manifest, move |status, reason| {
+    on_dev_exit(status, reason, exit_on_panic)
+  })?;
   let shared_process = Arc::new(Mutex::new(process));
+
   if let Err(e) = watch(
+    interface,
     shared_process.clone(),
-    manually_killed_app,
     tauri_path,
     merge_config,
     config,
     options,
-    runner,
     manifest,
-    cargo_features,
   ) {
     shared_process
       .lock()
@@ -286,6 +280,17 @@ fn command_internal(options: Options) -> Result<()> {
   }
 }
 
+fn on_dev_exit(status: ExitStatus, reason: ExitReason, exit_on_panic: bool) {
+  if !matches!(reason, ExitReason::TriggeredKill)
+    && (exit_on_panic || matches!(reason, ExitReason::NormalExit))
+  {
+    kill_before_dev_process();
+    #[cfg(not(debug_assertions))]
+    let _ = check_for_updates();
+    exit(status.code().unwrap_or(0));
+  }
+}
+
 #[cfg(not(debug_assertions))]
 fn check_for_updates() -> Result<()> {
   if std::env::var_os("TAURI_SKIP_UPDATE_CHECK") != Some("true".into()) {
@@ -328,16 +333,14 @@ fn lookup<F: FnMut(FileType, PathBuf)>(dir: &Path, mut f: F) {
 }
 
 #[allow(clippy::too_many_arguments)]
-fn watch(
-  process: Arc<Mutex<Arc<SharedChild>>>,
-  manually_killed_app: Arc<AtomicBool>,
+fn watch<P: DevProcess, I: Interface<Dev = P>>(
+  interface: I,
+  process: Arc<Mutex<P>>,
   tauri_path: PathBuf,
   merge_config: Option<String>,
   config: ConfigHandle,
   options: Options,
-  runner: String,
   mut manifest: Manifest,
-  cargo_features: Vec<String>,
 ) -> Result<()> {
   let (tx, rx) = channel();
 
@@ -355,6 +358,8 @@ fn watch(
     }
   });
 
+  let exit_on_panic = options.exit_on_panic;
+
   loop {
     if let Ok(event) = rx.recv() {
       let event_path = match event {
@@ -373,7 +378,6 @@ fn watch(
           // When tauri.conf.json is changed, rewrite_manifest will be called
           // which will trigger the watcher again
           // So the app should only be started when a file other than tauri.conf.json is changed
-          manually_killed_app.store(true, Ordering::Relaxed);
           let mut p = process.lock().unwrap();
           p.kill().with_context(|| "failed to kill app process")?;
           // wait for the process to exit
@@ -382,13 +386,9 @@ fn watch(
               break;
             }
           }
-          *p = start_app(
-            &options,
-            &runner,
-            &manifest,
-            &cargo_features,
-            manually_killed_app.clone(),
-          )?;
+          *p = interface.dev(options.clone().into(), &manifest, move |status, reason| {
+            on_dev_exit(status, reason, exit_on_panic)
+          })?;
         }
       }
     }
@@ -429,225 +429,3 @@ fn kill_before_dev_process() {
     let _ = child.kill();
   }
 }
-
-fn start_app(
-  options: &Options,
-  runner: &str,
-  manifest: &Manifest,
-  features: &[String],
-  manually_killed_app: Arc<AtomicBool>,
-) -> Result<Arc<SharedChild>> {
-  let mut command = Command::new(runner);
-  command
-    .env(
-      "CARGO_TERM_PROGRESS_WIDTH",
-      terminal::stderr_width()
-        .map(|width| {
-          if cfg!(windows) {
-            std::cmp::min(60, width)
-          } else {
-            width
-          }
-        })
-        .unwrap_or(if cfg!(windows) { 60 } else { 80 })
-        .to_string(),
-    )
-    .env("CARGO_TERM_PROGRESS_WHEN", "always");
-  command.arg("run").arg("--color").arg("always");
-
-  if !options.args.contains(&"--no-default-features".into()) {
-    let manifest_features = manifest.features();
-    let enable_features: Vec<String> = manifest_features
-      .get("default")
-      .cloned()
-      .unwrap_or_default()
-      .into_iter()
-      .filter(|feature| {
-        if let Some(manifest_feature) = manifest_features.get(feature) {
-          !manifest_feature.contains(&"tauri/custom-protocol".into())
-        } else {
-          feature != "tauri/custom-protocol"
-        }
-      })
-      .collect();
-    command.arg("--no-default-features");
-    if !enable_features.is_empty() {
-      command.args(&["--features", &enable_features.join(",")]);
-    }
-  }
-
-  if options.release_mode {
-    command.args(&["--release"]);
-  }
-
-  if let Some(target) = &options.target {
-    command.args(&["--target", target]);
-  }
-
-  if !features.is_empty() {
-    command.args(&["--features", &features.join(",")]);
-  }
-
-  if !options.args.is_empty() {
-    command.args(&options.args);
-  }
-
-  command.stdout(os_pipe::dup_stdout().unwrap());
-  command.stderr(Stdio::piped());
-
-  let child = match SharedChild::spawn(&mut command) {
-    Ok(c) => c,
-    Err(e) => {
-      if e.kind() == ErrorKind::NotFound {
-        return Err(anyhow::anyhow!(
-          "`{}` command not found.{}",
-          runner,
-          if runner == "cargo" {
-            " Please follow the Tauri setup guide: https://tauri.app/v1/guides/getting-started/prerequisites"
-          } else {
-            ""
-          }
-        ));
-      } else {
-        return Err(e.into());
-      }
-    }
-  };
-  let child_arc = Arc::new(child);
-  let child_stderr = child_arc.take_stderr().unwrap();
-  let mut stderr = BufReader::new(child_stderr);
-  let stderr_lines = Arc::new(Mutex::new(Vec::new()));
-  let stderr_lines_ = stderr_lines.clone();
-  std::thread::spawn(move || {
-    let mut buf = Vec::new();
-    let mut lines = stderr_lines_.lock().unwrap();
-    let mut io_stderr = std::io::stderr();
-    loop {
-      buf.clear();
-      match tauri_utils::io::read_line(&mut stderr, &mut buf) {
-        Ok(s) if s == 0 => break,
-        _ => (),
-      }
-      let _ = io_stderr.write_all(&buf);
-      if !buf.ends_with(&[b'\r']) {
-        let _ = io_stderr.write_all(b"\n");
-      }
-      lines.push(String::from_utf8_lossy(&buf).into_owned());
-    }
-  });
-
-  let child_clone = child_arc.clone();
-  let exit_on_panic = options.exit_on_panic;
-  std::thread::spawn(move || {
-    let status = child_clone.wait().expect("failed to wait on child");
-
-    if exit_on_panic {
-      if !manually_killed_app.load(Ordering::Relaxed) {
-        kill_before_dev_process();
-        #[cfg(not(debug_assertions))]
-        let _ = check_for_updates();
-        exit(status.code().unwrap_or(0));
-      }
-    } else {
-      let is_cargo_compile_error = stderr_lines
-        .lock()
-        .unwrap()
-        .last()
-        .map(|l| l.contains("could not compile"))
-        .unwrap_or_default();
-      stderr_lines.lock().unwrap().clear();
-
-      // if we're no exiting on panic, we only exit if:
-      // - the status is a success code (app closed)
-      // - status code is the Cargo error code
-      //    - and error is not a cargo compilation error (using stderr heuristics)
-      if status.success() || (status.code() == Some(101) && !is_cargo_compile_error) {
-        kill_before_dev_process();
-        #[cfg(not(debug_assertions))]
-        let _ = check_for_updates();
-        exit(status.code().unwrap_or(1));
-      }
-    }
-  });
-
-  Ok(child_arc)
-}
-
-// taken from https://github.com/rust-lang/cargo/blob/78b10d4e611ab0721fc3aeaf0edd5dd8f4fdc372/src/cargo/core/shell.rs#L514
-#[cfg(unix)]
-mod terminal {
-  use std::mem;
-
-  pub fn stderr_width() -> Option<usize> {
-    unsafe {
-      let mut winsize: libc::winsize = mem::zeroed();
-      // The .into() here is needed for FreeBSD which defines TIOCGWINSZ
-      // as c_uint but ioctl wants c_ulong.
-      #[allow(clippy::useless_conversion)]
-      if libc::ioctl(libc::STDERR_FILENO, libc::TIOCGWINSZ.into(), &mut winsize) < 0 {
-        return None;
-      }
-      if winsize.ws_col > 0 {
-        Some(winsize.ws_col as usize)
-      } else {
-        None
-      }
-    }
-  }
-}
-
-// taken from https://github.com/rust-lang/cargo/blob/78b10d4e611ab0721fc3aeaf0edd5dd8f4fdc372/src/cargo/core/shell.rs#L543
-#[cfg(windows)]
-mod terminal {
-  use std::{cmp, mem, ptr};
-  use winapi::um::fileapi::*;
-  use winapi::um::handleapi::*;
-  use winapi::um::processenv::*;
-  use winapi::um::winbase::*;
-  use winapi::um::wincon::*;
-  use winapi::um::winnt::*;
-
-  pub fn stderr_width() -> Option<usize> {
-    unsafe {
-      let stdout = GetStdHandle(STD_ERROR_HANDLE);
-      let mut csbi: CONSOLE_SCREEN_BUFFER_INFO = mem::zeroed();
-      if GetConsoleScreenBufferInfo(stdout, &mut csbi) != 0 {
-        return Some((csbi.srWindow.Right - csbi.srWindow.Left) as usize);
-      }
-
-      // On mintty/msys/cygwin based terminals, the above fails with
-      // INVALID_HANDLE_VALUE. Use an alternate method which works
-      // in that case as well.
-      let h = CreateFileA(
-        "CONOUT$\0".as_ptr() as *const CHAR,
-        GENERIC_READ | GENERIC_WRITE,
-        FILE_SHARE_READ | FILE_SHARE_WRITE,
-        ptr::null_mut(),
-        OPEN_EXISTING,
-        0,
-        ptr::null_mut(),
-      );
-      if h == INVALID_HANDLE_VALUE {
-        return None;
-      }
-
-      let mut csbi: CONSOLE_SCREEN_BUFFER_INFO = mem::zeroed();
-      let rc = GetConsoleScreenBufferInfo(h, &mut csbi);
-      CloseHandle(h);
-      if rc != 0 {
-        let width = (csbi.srWindow.Right - csbi.srWindow.Left) as usize;
-        // Unfortunately cygwin/mintty does not set the size of the
-        // backing console to match the actual window size. This
-        // always reports a size of 80 or 120 (not sure what
-        // determines that). Use a conservative max of 60 which should
-        // work in most circumstances. ConEmu does some magic to
-        // resize the console correctly, but there's no reasonable way
-        // to detect which kind of terminal we are running in, or if
-        // GetConsoleScreenBufferInfo returns accurate information.
-        return Some(cmp::min(60, width));
-      }
-
-      None
-    }
-  }
-}

+ 82 - 21
tooling/cli/src/interface/mod.rs

@@ -4,31 +4,92 @@
 
 pub mod rust;
 
-use std::path::Path;
+use std::{
+  path::{Path, PathBuf},
+  process::ExitStatus,
+};
 
 use crate::helpers::{config::Config, manifest::Manifest};
 use tauri_bundler::bundle::{PackageType, Settings, SettingsBuilder};
 
-#[allow(clippy::too_many_arguments)]
-pub fn get_bundler_settings(
-  app_settings: rust::AppSettings,
-  target: String,
-  features: &[String],
-  manifest: &Manifest,
-  config: &Config,
-  out_dir: &Path,
-  package_types: Option<Vec<PackageType>>,
-) -> crate::Result<Settings> {
-  let mut settings_builder = SettingsBuilder::new()
-    .package_settings(app_settings.get_package_settings())
-    .bundle_settings(app_settings.get_bundle_settings(config, manifest, features)?)
-    .binaries(app_settings.get_binaries(config, &target)?)
-    .project_out_directory(out_dir)
-    .target(target);
-
-  if let Some(types) = package_types {
-    settings_builder = settings_builder.package_types(types);
+pub use rust::{Options, Rust as AppInterface};
+
+pub trait AppSettings {
+  fn get_package_settings(&self) -> tauri_bundler::PackageSettings;
+  fn get_bundle_settings(
+    &self,
+    config: &Config,
+    manifest: &Manifest,
+    features: &[String],
+  ) -> crate::Result<tauri_bundler::BundleSettings>;
+  fn app_binary_path(&self, options: &Options) -> crate::Result<PathBuf>;
+  fn get_binaries(
+    &self,
+    config: &Config,
+    target: &str,
+  ) -> crate::Result<Vec<tauri_bundler::BundleBinary>>;
+
+  fn get_bundler_settings(
+    &self,
+    options: &Options,
+    manifest: &Manifest,
+    config: &Config,
+    out_dir: &Path,
+    package_types: Option<Vec<PackageType>>,
+  ) -> crate::Result<Settings> {
+    let no_default_features = options.args.contains(&"--no-default-features".into());
+    let mut enabled_features = options.features.clone().unwrap_or_default();
+    if !no_default_features {
+      enabled_features.push("default".into());
+    }
+
+    let target: String = if let Some(target) = options.target.clone() {
+      target
+    } else {
+      tauri_utils::platform::target_triple()?
+    };
+
+    let mut settings_builder = SettingsBuilder::new()
+      .package_settings(self.get_package_settings())
+      .bundle_settings(self.get_bundle_settings(config, manifest, &enabled_features)?)
+      .binaries(self.get_binaries(config, &target)?)
+      .project_out_directory(out_dir)
+      .target(target);
+
+    if let Some(types) = package_types {
+      settings_builder = settings_builder.package_types(types);
+    }
+
+    settings_builder.build().map_err(Into::into)
   }
+}
+
+pub trait DevProcess {
+  fn kill(&self) -> std::io::Result<()>;
+  fn try_wait(&self) -> std::io::Result<Option<ExitStatus>>;
+}
+
+#[derive(Debug)]
+pub enum ExitReason {
+  /// Killed manually.
+  TriggeredKill,
+  /// App compilation failed.
+  CompilationFailed,
+  /// Regular exit.
+  NormalExit,
+}
+
+pub trait Interface: Sized {
+  type AppSettings: AppSettings;
+  type Dev: DevProcess;
 
-  settings_builder.build().map_err(Into::into)
+  fn new(config: &Config) -> crate::Result<Self>;
+  fn app_settings(&self) -> &Self::AppSettings;
+  fn build(&self, options: Options) -> crate::Result<()>;
+  fn dev<F: FnOnce(ExitStatus, ExitReason) + Send + 'static>(
+    &self,
+    options: Options,
+    manifest: &Manifest,
+    on_exit: F,
+  ) -> crate::Result<Self::Dev>;
 }

+ 567 - 96
tooling/cli/src/interface/rust.rs

@@ -3,11 +3,15 @@
 // SPDX-License-Identifier: MIT
 
 use std::{
-  fs::File,
-  io::{ErrorKind, Read},
+  fs::{rename, File},
+  io::{BufReader, ErrorKind, Read, Write},
   path::{Path, PathBuf},
-  process::Command,
+  process::{Command, ExitStatus, Stdio},
   str::FromStr,
+  sync::{
+    atomic::{AtomicBool, Ordering},
+    Arc, Mutex,
+  },
 };
 
 use anyhow::Context;
@@ -15,7 +19,13 @@ use anyhow::Context;
 use heck::ToKebabCase;
 use log::warn;
 use serde::Deserialize;
+use shared_child::SharedChild;
+use tauri_bundler::{
+  AppCategory, BundleBinary, BundleSettings, DebianSettings, MacOsSettings, PackageSettings,
+  UpdaterSettings, WindowsSettings,
+};
 
+use super::{AppSettings, DevProcess, ExitReason, Interface};
 use crate::{
   helpers::{
     app_paths::tauri_dir,
@@ -24,10 +34,375 @@ use crate::{
   },
   CommandExt,
 };
-use tauri_bundler::{
-  AppCategory, BundleBinary, BundleSettings, DebianSettings, MacOsSettings, PackageSettings,
-  UpdaterSettings, WindowsSettings,
-};
+
+#[derive(Debug, Clone)]
+pub struct Options {
+  pub runner: Option<String>,
+  pub debug: bool,
+  pub target: Option<String>,
+  pub features: Option<Vec<String>>,
+  pub args: Vec<String>,
+}
+
+impl From<crate::build::Options> for Options {
+  fn from(options: crate::build::Options) -> Self {
+    Self {
+      runner: options.runner,
+      debug: options.debug,
+      target: options.target,
+      features: options.features,
+      args: options.args,
+    }
+  }
+}
+
+impl From<crate::dev::Options> for Options {
+  fn from(options: crate::dev::Options) -> Self {
+    Self {
+      runner: options.runner,
+      debug: !options.release_mode,
+      target: options.target,
+      features: options.features,
+      args: options.args,
+    }
+  }
+}
+
+pub struct DevChild {
+  manually_killed_app: Arc<AtomicBool>,
+  build_child: Arc<SharedChild>,
+  app_child: Arc<Mutex<Option<Arc<SharedChild>>>>,
+}
+
+impl DevProcess for DevChild {
+  fn kill(&self) -> std::io::Result<()> {
+    if let Some(child) = &*self.app_child.lock().unwrap() {
+      child.kill()?;
+    } else {
+      self.build_child.kill()?;
+    }
+    self.manually_killed_app.store(true, Ordering::Relaxed);
+    Ok(())
+  }
+
+  fn try_wait(&self) -> std::io::Result<Option<ExitStatus>> {
+    if let Some(child) = &*self.app_child.lock().unwrap() {
+      child.try_wait()
+    } else {
+      self.build_child.try_wait()
+    }
+  }
+}
+
+pub struct Rust {
+  app_settings: RustAppSettings,
+  config_features: Vec<String>,
+  product_name: Option<String>,
+}
+
+impl Interface for Rust {
+  type AppSettings = RustAppSettings;
+  type Dev = DevChild;
+
+  fn new(config: &Config) -> crate::Result<Self> {
+    Ok(Self {
+      app_settings: RustAppSettings::new(config)?,
+      config_features: config.build.features.clone().unwrap_or_default(),
+      product_name: config.package.product_name.clone(),
+    })
+  }
+
+  fn app_settings(&self) -> &Self::AppSettings {
+    &self.app_settings
+  }
+
+  fn build(&self, options: Options) -> crate::Result<()> {
+    let bin_path = self.app_settings.app_binary_path(&options)?;
+    let out_dir = bin_path.parent().unwrap();
+
+    let bin_name = bin_path.file_stem().unwrap();
+
+    if options.target == Some("universal-apple-darwin".into()) {
+      std::fs::create_dir_all(&out_dir)
+        .with_context(|| "failed to create project out directory")?;
+
+      let mut lipo_cmd = Command::new("lipo");
+      lipo_cmd
+        .arg("-create")
+        .arg("-output")
+        .arg(out_dir.join(&bin_name));
+      for triple in ["aarch64-apple-darwin", "x86_64-apple-darwin"] {
+        let mut options = options.clone();
+        options.target.replace(triple.into());
+
+        let triple_out_dir = self
+          .app_settings
+          .out_dir(Some(triple.into()), options.debug)
+          .with_context(|| format!("failed to get {} out dir", triple))?;
+        self
+          .build_app(options)
+          .with_context(|| format!("failed to build {} binary", triple))?;
+
+        lipo_cmd.arg(triple_out_dir.join(&bin_name));
+      }
+
+      let lipo_status = lipo_cmd.output_ok()?.status;
+      if !lipo_status.success() {
+        return Err(anyhow::anyhow!(format!(
+          "Result of `lipo` command was unsuccessful: {}. (Is `lipo` installed?)",
+          lipo_status
+        )));
+      }
+    } else {
+      self
+        .build_app(options)
+        .with_context(|| "failed to build app")?;
+    }
+
+    rename_app(bin_path, self.product_name.as_deref())?;
+
+    Ok(())
+  }
+
+  fn dev<F: FnOnce(ExitStatus, ExitReason) + Send + 'static>(
+    &self,
+    options: Options,
+    manifest: &Manifest,
+    on_exit: F,
+  ) -> crate::Result<Self::Dev> {
+    let bin_path = self.app_settings.app_binary_path(&options)?;
+    let product_name = self.product_name.clone();
+
+    let runner = options.runner.unwrap_or_else(|| "cargo".into());
+    let mut build_cmd = Command::new(&runner);
+    build_cmd
+      .env(
+        "CARGO_TERM_PROGRESS_WIDTH",
+        terminal::stderr_width()
+          .map(|width| {
+            if cfg!(windows) {
+              std::cmp::min(60, width)
+            } else {
+              width
+            }
+          })
+          .unwrap_or(if cfg!(windows) { 60 } else { 80 })
+          .to_string(),
+      )
+      .env("CARGO_TERM_PROGRESS_WHEN", "always");
+    build_cmd.arg("build").arg("--color").arg("always");
+
+    if !options.args.contains(&"--no-default-features".into()) {
+      let manifest_features = manifest.features();
+      let enable_features: Vec<String> = manifest_features
+        .get("default")
+        .cloned()
+        .unwrap_or_default()
+        .into_iter()
+        .filter(|feature| {
+          if let Some(manifest_feature) = manifest_features.get(feature) {
+            !manifest_feature.contains(&"tauri/custom-protocol".into())
+          } else {
+            feature != "tauri/custom-protocol"
+          }
+        })
+        .collect();
+      build_cmd.arg("--no-default-features");
+      if !enable_features.is_empty() {
+        build_cmd.args(&["--features", &enable_features.join(",")]);
+      }
+    }
+
+    if !options.debug {
+      build_cmd.args(&["--release"]);
+    }
+
+    if let Some(target) = &options.target {
+      build_cmd.args(&["--target", target]);
+    }
+
+    let mut features = self.config_features.clone();
+    if let Some(f) = options.features {
+      features.extend(f);
+    }
+    if !features.is_empty() {
+      build_cmd.args(&["--features", &features.join(",")]);
+    }
+
+    let mut run_args = Vec::new();
+    let mut reached_run_args = false;
+    for arg in options.args.clone() {
+      if reached_run_args {
+        run_args.push(arg);
+      } else if arg == "--" {
+        reached_run_args = true;
+      } else {
+        build_cmd.arg(arg);
+      }
+    }
+
+    build_cmd.stdout(os_pipe::dup_stdout()?);
+    build_cmd.stderr(Stdio::piped());
+
+    let manually_killed_app = Arc::new(AtomicBool::default());
+    let manually_killed_app_ = manually_killed_app.clone();
+
+    let build_child = match SharedChild::spawn(&mut build_cmd) {
+      Ok(c) => c,
+      Err(e) => {
+        if e.kind() == ErrorKind::NotFound {
+          return Err(anyhow::anyhow!(
+            "`{}` command not found.{}",
+            runner,
+            if runner == "cargo" {
+              " Please follow the Tauri setup guide: https://tauri.app/v1/guides/getting-started/prerequisites"
+            } else {
+              ""
+            }
+          ));
+        } else {
+          return Err(e.into());
+        }
+      }
+    };
+    let build_child = Arc::new(build_child);
+    let build_child_stderr = build_child.take_stderr().unwrap();
+    let mut stderr = BufReader::new(build_child_stderr);
+    let stderr_lines = Arc::new(Mutex::new(Vec::new()));
+    let stderr_lines_ = stderr_lines.clone();
+    std::thread::spawn(move || {
+      let mut buf = Vec::new();
+      let mut lines = stderr_lines_.lock().unwrap();
+      let mut io_stderr = std::io::stderr();
+      loop {
+        buf.clear();
+        match tauri_utils::io::read_line(&mut stderr, &mut buf) {
+          Ok(s) if s == 0 => break,
+          _ => (),
+        }
+        let _ = io_stderr.write_all(&buf);
+        if !buf.ends_with(&[b'\r']) {
+          let _ = io_stderr.write_all(b"\n");
+        }
+        lines.push(String::from_utf8_lossy(&buf).into_owned());
+      }
+    });
+
+    let build_child_ = build_child.clone();
+    let app_child = Arc::new(Mutex::new(None));
+    let app_child_ = app_child.clone();
+    std::thread::spawn(move || {
+      let status = build_child_.wait().expect("failed to wait on build");
+
+      if status.success() {
+        let bin_path = rename_app(bin_path, product_name.as_deref()).expect("failed to rename app");
+
+        let mut app = Command::new(bin_path);
+        app.stdout(os_pipe::dup_stdout().unwrap());
+        app.stderr(os_pipe::dup_stderr().unwrap());
+        app.args(run_args);
+        let app_child = Arc::new(SharedChild::spawn(&mut app).unwrap());
+        let app_child_t = app_child.clone();
+        std::thread::spawn(move || {
+          let status = app_child_t.wait().expect("failed to wait on app");
+          on_exit(
+            status,
+            if manually_killed_app_.load(Ordering::Relaxed) {
+              ExitReason::TriggeredKill
+            } else {
+              ExitReason::NormalExit
+            },
+          );
+        });
+
+        app_child_.lock().unwrap().replace(app_child);
+      } else {
+        let is_cargo_compile_error = stderr_lines
+          .lock()
+          .unwrap()
+          .last()
+          .map(|l| l.contains("could not compile"))
+          .unwrap_or_default();
+        stderr_lines.lock().unwrap().clear();
+
+        on_exit(
+          status,
+          if status.code() == Some(101) && is_cargo_compile_error {
+            ExitReason::CompilationFailed
+          } else {
+            ExitReason::NormalExit
+          },
+        );
+      }
+    });
+
+    Ok(DevChild {
+      manually_killed_app,
+      build_child,
+      app_child,
+    })
+  }
+}
+
+impl Rust {
+  fn build_app(&self, options: Options) -> crate::Result<()> {
+    let runner = options.runner.unwrap_or_else(|| "cargo".into());
+
+    let mut args = Vec::new();
+    if !options.args.is_empty() {
+      args.extend(options.args);
+    }
+
+    if let Some(features) = options.features {
+      if !features.is_empty() {
+        args.push("--features".into());
+        args.push(features.join(","));
+      }
+    }
+
+    if !options.debug {
+      args.push("--release".into());
+    }
+
+    if let Some(target) = options.target {
+      args.push("--target".into());
+      args.push(target);
+    }
+
+    match Command::new(&runner)
+      .args(&["build", "--features=custom-protocol"])
+      .args(args)
+      .env("STATIC_VCRUNTIME", "true")
+      .piped()
+    {
+      Ok(status) => {
+        if status.success() {
+          Ok(())
+        } else {
+          Err(anyhow::anyhow!(
+            "Result of `{} build` operation was unsuccessful",
+            runner
+          ))
+        }
+      }
+      Err(e) => {
+        if e.kind() == ErrorKind::NotFound {
+          Err(anyhow::anyhow!(
+            "`{}` command not found.{}",
+            runner,
+            if runner == "cargo" {
+              " Please follow the Tauri setup guide: https://tauri.app/v1/guides/getting-started/prerequisites"
+            } else {
+              ""
+            }
+          ))
+        } else {
+          Err(e.into())
+        }
+      }
+    }
+  }
+}
 
 /// The `workspace` section of the app configuration (read from Cargo.toml).
 #[derive(Clone, Debug, Deserialize)]
@@ -100,94 +475,18 @@ struct CargoConfig {
   build: Option<CargoBuildConfig>,
 }
 
-pub fn build_project(runner: String, args: Vec<String>) -> crate::Result<()> {
-  match Command::new(&runner)
-    .args(&["build", "--features=custom-protocol"])
-    .args(args)
-    .env("STATIC_VCRUNTIME", "true")
-    .piped()
-  {
-    Ok(status) => {
-      if status.success() {
-        Ok(())
-      } else {
-        Err(anyhow::anyhow!(
-          "Result of `{} build` operation was unsuccessful",
-          runner
-        ))
-      }
-    }
-    Err(e) => {
-      if e.kind() == ErrorKind::NotFound {
-        Err(anyhow::anyhow!(
-          "`{}` command not found.{}",
-          runner,
-          if runner == "cargo" {
-            " Please follow the Tauri setup guide: https://tauri.app/v1/guides/getting-started/prerequisites"
-          } else {
-            ""
-          }
-        ))
-      } else {
-        Err(e.into())
-      }
-    }
-  }
-}
-
-pub struct AppSettings {
+pub struct RustAppSettings {
   cargo_settings: CargoSettings,
   cargo_package_settings: CargoPackageSettings,
   package_settings: PackageSettings,
 }
 
-impl AppSettings {
-  pub fn new(config: &Config) -> crate::Result<Self> {
-    let cargo_settings =
-      CargoSettings::load(&tauri_dir()).with_context(|| "failed to load cargo settings")?;
-    let cargo_package_settings = match &cargo_settings.package {
-      Some(package_info) => package_info.clone(),
-      None => {
-        return Err(anyhow::anyhow!(
-          "No package info in the config file".to_owned(),
-        ))
-      }
-    };
-
-    let package_settings = PackageSettings {
-      product_name: config.package.product_name.clone().unwrap_or_else(|| {
-        cargo_package_settings
-          .name
-          .clone()
-          .expect("Cargo manifest must have the `package.name` field")
-      }),
-      version: config.package.version.clone().unwrap_or_else(|| {
-        cargo_package_settings
-          .version
-          .clone()
-          .expect("Cargo manifest must have the `package.version` field")
-      }),
-      description: cargo_package_settings
-        .description
-        .clone()
-        .unwrap_or_default(),
-      homepage: cargo_package_settings.homepage.clone(),
-      authors: cargo_package_settings.authors.clone(),
-      default_run: cargo_package_settings.default_run.clone(),
-    };
-
-    Ok(Self {
-      cargo_settings,
-      cargo_package_settings,
-      package_settings,
-    })
-  }
-
-  pub fn cargo_package_settings(&self) -> &CargoPackageSettings {
-    &self.cargo_package_settings
+impl AppSettings for RustAppSettings {
+  fn get_package_settings(&self) -> PackageSettings {
+    self.package_settings.clone()
   }
 
-  pub fn get_bundle_settings(
+  fn get_bundle_settings(
     &self,
     config: &Config,
     manifest: &Manifest,
@@ -202,17 +501,33 @@ impl AppSettings {
     )
   }
 
-  pub fn get_out_dir(&self, target: Option<String>, debug: bool) -> crate::Result<PathBuf> {
-    let tauri_dir = tauri_dir();
-    let workspace_dir = get_workspace_dir(&tauri_dir);
-    get_target_dir(&workspace_dir, target, !debug)
-  }
+  fn app_binary_path(&self, options: &Options) -> crate::Result<PathBuf> {
+    let bin_name = self
+      .cargo_package_settings()
+      .name
+      .clone()
+      .expect("Cargo manifest must have the `package.name` field");
+
+    let out_dir = self
+      .out_dir(options.target.clone(), options.debug)
+      .with_context(|| "failed to get project out directory")?;
+    let target: String = if let Some(target) = options.target.clone() {
+      target
+    } else {
+      tauri_utils::platform::target_triple()?
+    };
 
-  pub fn get_package_settings(&self) -> PackageSettings {
-    self.package_settings.clone()
+    let binary_extension: String = if target.contains("windows") {
+      "exe"
+    } else {
+      ""
+    }
+    .into();
+
+    Ok(out_dir.join(bin_name).with_extension(&binary_extension))
   }
 
-  pub fn get_binaries(&self, config: &Config, target: &str) -> crate::Result<Vec<BundleBinary>> {
+  fn get_binaries(&self, config: &Config, target: &str) -> crate::Result<Vec<BundleBinary>> {
     let mut binaries: Vec<BundleBinary> = vec![];
 
     let binary_extension: String = if target.contains("windows") {
@@ -317,6 +632,59 @@ impl AppSettings {
   }
 }
 
+impl RustAppSettings {
+  pub fn new(config: &Config) -> crate::Result<Self> {
+    let cargo_settings =
+      CargoSettings::load(&tauri_dir()).with_context(|| "failed to load cargo settings")?;
+    let cargo_package_settings = match &cargo_settings.package {
+      Some(package_info) => package_info.clone(),
+      None => {
+        return Err(anyhow::anyhow!(
+          "No package info in the config file".to_owned(),
+        ))
+      }
+    };
+
+    let package_settings = PackageSettings {
+      product_name: config.package.product_name.clone().unwrap_or_else(|| {
+        cargo_package_settings
+          .name
+          .clone()
+          .expect("Cargo manifest must have the `package.name` field")
+      }),
+      version: config.package.version.clone().unwrap_or_else(|| {
+        cargo_package_settings
+          .version
+          .clone()
+          .expect("Cargo manifest must have the `package.version` field")
+      }),
+      description: cargo_package_settings
+        .description
+        .clone()
+        .unwrap_or_default(),
+      homepage: cargo_package_settings.homepage.clone(),
+      authors: cargo_package_settings.authors.clone(),
+      default_run: cargo_package_settings.default_run.clone(),
+    };
+
+    Ok(Self {
+      cargo_settings,
+      cargo_package_settings,
+      package_settings,
+    })
+  }
+
+  pub fn cargo_package_settings(&self) -> &CargoPackageSettings {
+    &self.cargo_package_settings
+  }
+
+  pub fn out_dir(&self, target: Option<String>, debug: bool) -> crate::Result<PathBuf> {
+    let tauri_dir = tauri_dir();
+    let workspace_dir = get_workspace_dir(&tauri_dir);
+    get_target_dir(&workspace_dir, target, !debug)
+  }
+}
+
 /// This function determines where 'target' dir is and suffixes it with 'release' or 'debug'
 /// to determine where the compiled binary will be located.
 fn get_target_dir(
@@ -550,3 +918,106 @@ fn tauri_config_to_bundle_settings(
     ..Default::default()
   })
 }
+
+fn rename_app(bin_path: PathBuf, product_name: Option<&str>) -> crate::Result<PathBuf> {
+  if let Some(product_name) = product_name {
+    #[cfg(target_os = "linux")]
+    let product_name = product_name.to_kebab_case();
+
+    let product_path = bin_path
+      .parent()
+      .unwrap()
+      .join(&product_name)
+      .with_extension(bin_path.extension().unwrap_or_default());
+
+    rename(&bin_path, &product_path).with_context(|| {
+      format!(
+        "failed to rename `{}` to `{}`",
+        bin_path.display(),
+        product_path.display(),
+      )
+    })?;
+    Ok(product_path)
+  } else {
+    Ok(bin_path)
+  }
+}
+
+// taken from https://github.com/rust-lang/cargo/blob/78b10d4e611ab0721fc3aeaf0edd5dd8f4fdc372/src/cargo/core/shell.rs#L514
+#[cfg(unix)]
+mod terminal {
+  use std::mem;
+
+  pub fn stderr_width() -> Option<usize> {
+    unsafe {
+      let mut winsize: libc::winsize = mem::zeroed();
+      // The .into() here is needed for FreeBSD which defines TIOCGWINSZ
+      // as c_uint but ioctl wants c_ulong.
+      #[allow(clippy::useless_conversion)]
+      if libc::ioctl(libc::STDERR_FILENO, libc::TIOCGWINSZ.into(), &mut winsize) < 0 {
+        return None;
+      }
+      if winsize.ws_col > 0 {
+        Some(winsize.ws_col as usize)
+      } else {
+        None
+      }
+    }
+  }
+}
+
+// taken from https://github.com/rust-lang/cargo/blob/78b10d4e611ab0721fc3aeaf0edd5dd8f4fdc372/src/cargo/core/shell.rs#L543
+#[cfg(windows)]
+mod terminal {
+  use std::{cmp, mem, ptr};
+  use winapi::um::fileapi::*;
+  use winapi::um::handleapi::*;
+  use winapi::um::processenv::*;
+  use winapi::um::winbase::*;
+  use winapi::um::wincon::*;
+  use winapi::um::winnt::*;
+
+  pub fn stderr_width() -> Option<usize> {
+    unsafe {
+      let stdout = GetStdHandle(STD_ERROR_HANDLE);
+      let mut csbi: CONSOLE_SCREEN_BUFFER_INFO = mem::zeroed();
+      if GetConsoleScreenBufferInfo(stdout, &mut csbi) != 0 {
+        return Some((csbi.srWindow.Right - csbi.srWindow.Left) as usize);
+      }
+
+      // On mintty/msys/cygwin based terminals, the above fails with
+      // INVALID_HANDLE_VALUE. Use an alternate method which works
+      // in that case as well.
+      let h = CreateFileA(
+        "CONOUT$\0".as_ptr() as *const CHAR,
+        GENERIC_READ | GENERIC_WRITE,
+        FILE_SHARE_READ | FILE_SHARE_WRITE,
+        ptr::null_mut(),
+        OPEN_EXISTING,
+        0,
+        ptr::null_mut(),
+      );
+      if h == INVALID_HANDLE_VALUE {
+        return None;
+      }
+
+      let mut csbi: CONSOLE_SCREEN_BUFFER_INFO = mem::zeroed();
+      let rc = GetConsoleScreenBufferInfo(h, &mut csbi);
+      CloseHandle(h);
+      if rc != 0 {
+        let width = (csbi.srWindow.Right - csbi.srWindow.Left) as usize;
+        // Unfortunately cygwin/mintty does not set the size of the
+        // backing console to match the actual window size. This
+        // always reports a size of 80 or 120 (not sure what
+        // determines that). Use a conservative max of 60 which should
+        // work in most circumstances. ConEmu does some magic to
+        // resize the console correctly, but there's no reasonable way
+        // to detect which kind of terminal we are running in, or if
+        // GetConsoleScreenBufferInfo returns accurate information.
+        return Some(cmp::min(60, width));
+      }
+
+      None
+    }
+  }
+}

+ 25 - 6
tooling/cli/src/lib.rs

@@ -18,9 +18,12 @@ use env_logger::fmt::Color;
 use env_logger::Builder;
 use log::{debug, log_enabled, Level};
 use serde::Deserialize;
-use std::ffi::OsString;
 use std::io::{BufReader, Write};
-use std::process::{Command, ExitStatus, Stdio};
+use std::process::{Command, ExitStatus, Output, Stdio};
+use std::{
+  ffi::OsString,
+  sync::{Arc, Mutex},
+};
 
 #[derive(Deserialize)]
 pub struct VersionMetadata {
@@ -177,7 +180,7 @@ pub trait CommandExt {
   // The `pipe` function sets the stdout and stderr to properly
   // show the command output in the Node.js wrapper.
   fn piped(&mut self) -> std::io::Result<ExitStatus>;
-  fn output_ok(&mut self) -> crate::Result<()>;
+  fn output_ok(&mut self) -> crate::Result<Output>;
 }
 
 impl CommandExt for Command {
@@ -190,7 +193,7 @@ impl CommandExt for Command {
     self.status().map_err(Into::into)
   }
 
-  fn output_ok(&mut self) -> crate::Result<()> {
+  fn output_ok(&mut self) -> crate::Result<Output> {
     let program = self.get_program().to_string_lossy().into_owned();
     debug!(action = "Running"; "Command `{} {}`", program, self.get_args().map(|arg| arg.to_string_lossy()).fold(String::new(), |acc, arg| format!("{} {}", acc, arg)));
 
@@ -200,8 +203,11 @@ impl CommandExt for Command {
     let mut child = self.spawn()?;
 
     let mut stdout = child.stdout.take().map(BufReader::new).unwrap();
+    let stdout_lines = Arc::new(Mutex::new(Vec::new()));
+    let stdout_lines_ = stdout_lines.clone();
     std::thread::spawn(move || {
       let mut buf = Vec::new();
+      let mut lines = stdout_lines_.lock().unwrap();
       loop {
         buf.clear();
         match tauri_utils::io::read_line(&mut stdout, &mut buf) {
@@ -209,12 +215,17 @@ impl CommandExt for Command {
           _ => (),
         }
         debug!(action = "stdout"; "{}", String::from_utf8_lossy(&buf));
+        lines.extend(buf.clone());
+        lines.push(b'\n');
       }
     });
 
     let mut stderr = child.stderr.take().map(BufReader::new).unwrap();
+    let stderr_lines = Arc::new(Mutex::new(Vec::new()));
+    let stderr_lines_ = stderr_lines.clone();
     std::thread::spawn(move || {
       let mut buf = Vec::new();
+      let mut lines = stderr_lines_.lock().unwrap();
       loop {
         buf.clear();
         match tauri_utils::io::read_line(&mut stderr, &mut buf) {
@@ -222,13 +233,21 @@ impl CommandExt for Command {
           _ => (),
         }
         debug!(action = "stderr"; "{}", String::from_utf8_lossy(&buf));
+        lines.extend(buf.clone());
+        lines.push(b'\n');
       }
     });
 
     let status = child.wait()?;
 
-    if status.success() {
-      Ok(())
+    let output = Output {
+      status,
+      stdout: std::mem::take(&mut *stdout_lines.lock().unwrap()),
+      stderr: std::mem::take(&mut *stderr_lines.lock().unwrap()),
+    };
+
+    if output.status.success() {
+      Ok(output)
     } else {
       Err(anyhow::anyhow!("failed to run {}", program))
     }