snapshot of https://github.com/tauri-apps/tauri.git (dev, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Audit / audit-js (push) Failing after 0s
bench / bench (map[os:ubuntu-latest runner:xvfb-run --auto-servernum target:x86_64-unknown-linux-gnu], nightly) (push) Failing after 0s
covector version or publish / run-integration-tests (ubuntu-latest) (push) Failing after 0s
Audit / audit-rust (push) Failing after 0s
test `@tauri-apps/cli` / test (ubuntu-latest) (push) Failing after 0s
test `tauri-cli` / test (map[os:ubuntu-latest target:x86_64-unknown-linux-gnu]) (push) Failing after 0s
test core / test (map[args:--all-features key:all], map[cargo:cargo command:test os:ubuntu-latest target:x86_64-unknown-linux-gnu toolchain:1.90]) (push) Failing after 0s
test core / test (map[args:--no-default-features key:no-default], map[cargo:cargo command:test os:ubuntu-latest target:x86_64-unknown-linux-gnu toolchain:1.90]) (push) Failing after 0s
test core / test (map[args:--no-default-features key:no-default], map[cargo:cross command:build os:ubuntu-latest target:aarch64-linux-android toolchain:1.90]) (push) Failing after 0s
deploy schema worker / deploy (push) Failing after 0s
lint rust / clippy (map[cargo:cargo include: os:ubuntu-latest target:x86_64-unknown-linux-gnu]) (push) Failing after 0s
lint rust / clippy (map[cargo:cross include:--package api os:ubuntu-latest target:aarch64-linux-android]) (push) Failing after 0s
supply chain health status / check rust dependencies with cargo vet (push) Failing after 0s
test `@tauri-apps/api` e2e (mobile) / android (push) Failing after 0s
test `@tauri-apps/api` e2e / test (map[name:Linux os:ubuntu-latest]) (push) Failing after 0s
test core / test (map[args:--all-features key:all], map[cargo:cross command:build os:ubuntu-latest target:aarch64-linux-android toolchain:1.90]) (push) Failing after 0s
Udeps / changes (push) Failing after 0s
Udeps / setup (push) Has been skipped
Udeps / udeps (push) Has been skipped
bench / bench (map[os:macos-latest runner: target:aarch64-apple-darwin], nightly) (push) Has been cancelled
bench / bench (map[os:windows-latest runner: target:x86_64-pc-windows-msvc], nightly) (push) Has been cancelled
covector version or publish / run-integration-tests (macos-latest) (push) Has been cancelled
covector version or publish / run-integration-tests (windows-latest) (push) Has been cancelled
covector version or publish / version-or-publish (push) Has been cancelled
test `@tauri-apps/cli` / test (macos-latest) (push) Has been cancelled
test `tauri-cli` / test (map[args:--no-default-features --features native-tls-vendored os:windows-latest restore-only:true target:aarch64-pc-windows-msvc]) (push) Has been cancelled
test `tauri-cli` / test (map[os:macos-latest target:x86_64-apple-darwin]) (push) Has been cancelled
test `tauri-cli` / test (map[os:windows-latest target:x86_64-pc-windows-msvc]) (push) Has been cancelled
test core / test (map[args:--all-features key:all], map[cargo:cargo command:build os:macos-latest target:aarch64-apple-ios toolchain:1.90]) (push) Has been cancelled
test core / test (map[args:--all-features key:all], map[cargo:cargo command:test os:macos-14 target:aarch64-apple-darwin toolchain:1.90]) (push) Has been cancelled
lint rust / clippy (map[cargo:cargo include: os:macos-latest target:aarch64-apple-darwin]) (push) Has been cancelled
lint rust / clippy (map[cargo:cargo include: os:windows-latest target:x86_64-pc-windows-msvc]) (push) Has been cancelled
lint rust / clippy (map[cargo:cargo include:--package api os:macos-latest target:aarch64-apple-ios]) (push) Has been cancelled
test `@tauri-apps/api` e2e (mobile) / ios (push) Has been cancelled
test `@tauri-apps/api` e2e / test (map[name:Windows os:windows-latest]) (push) Has been cancelled
test `@tauri-apps/api` e2e / test (map[name:macOS os:macos-latest]) (push) Has been cancelled
test `@tauri-apps/cli` / test (windows-latest) (push) Has been cancelled
test core / test (map[args:--all-features key:all], map[cargo:cargo command:test os:windows-latest target:x86_64-pc-windows-msvc toolchain:1.90]) (push) Has been cancelled
test core / test (map[args:--no-default-features key:no-default], map[cargo:cargo command:build os:macos-latest target:aarch64-apple-ios toolchain:1.90]) (push) Has been cancelled
test core / test (map[args:--no-default-features key:no-default], map[cargo:cargo command:test os:macos-14 target:aarch64-apple-darwin toolchain:1.90]) (push) Has been cancelled
test core / test (map[args:--no-default-features key:no-default], map[cargo:cargo command:test os:windows-latest target:x86_64-pc-windows-msvc toolchain:1.90]) (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-23 17:00:42 +05:00
commit feda81bf2f
1178 changed files with 156404 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use clap::{Parser, Subcommand};
use crate::Result;
mod new;
#[derive(Debug, Parser)]
#[clap(about = "Manage or create capabilities for your app")]
pub struct Cli {
#[clap(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
#[clap(alias = "create")]
New(new::Options),
}
pub fn command(cli: Cli) -> Result<()> {
match cli.command {
Commands::New(options) => new::command(options),
}
}

View File

@@ -0,0 +1,144 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::{collections::HashSet, path::PathBuf};
use clap::Parser;
use tauri_utils::acl::capability::{Capability, PermissionEntry};
use crate::{Result, acl::FileFormat, error::ErrorExt, helpers::prompts};
#[derive(Debug, Parser)]
#[clap(about = "Create a new capability file")]
pub struct Options {
/// Capability identifier.
identifier: Option<String>,
/// Capability description
#[clap(long)]
description: Option<String>,
/// Capability windows
#[clap(long)]
windows: Option<Vec<String>>,
/// Capability permissions
#[clap(long)]
permission: Option<Vec<String>>,
/// Output file format.
#[clap(long, default_value_t = FileFormat::Json)]
format: FileFormat,
/// The output file.
#[clap(short, long)]
out: Option<PathBuf>,
}
pub fn command(options: Options) -> Result<()> {
let dirs = crate::helpers::app_paths::resolve_dirs();
let identifier = match options.identifier {
Some(i) => i,
None => prompts::input("What's the capability identifier?", None, false, false)?.unwrap(),
};
let description = match options.description {
Some(d) => Some(d),
None => prompts::input::<String>("What's the capability description?", None, false, true)?
.filter(|d| !d.is_empty()),
};
let windows = match options.windows.map(FromIterator::from_iter) {
Some(w) => w,
None => prompts::input::<String>(
"Which windows should be affected by this? (comma separated)",
Some("main".into()),
false,
false,
)?
.and_then(|d| {
if d.is_empty() {
None
} else {
Some(d.split(',').map(ToString::to_string).collect())
}
})
.unwrap_or_default(),
};
let permissions: HashSet<String> = match options.permission.map(FromIterator::from_iter) {
Some(p) => p,
None => prompts::input::<String>(
"What permissions to enable? (comma separated)",
None,
false,
true,
)?
.and_then(|p| {
if p.is_empty() {
None
} else {
Some(p.split(',').map(ToString::to_string).collect())
}
})
.unwrap_or_default(),
};
let capability = Capability {
identifier,
description: description.unwrap_or_default(),
remote: None,
local: true,
windows,
webviews: Vec::new(),
permissions: permissions
.into_iter()
.map(|p| {
PermissionEntry::PermissionRef(
p.clone()
.try_into()
.unwrap_or_else(|_| panic!("invalid permission {p}")),
)
})
.collect(),
platforms: None,
};
let path = match options.out {
Some(o) => o
.canonicalize()
.fs_context("failed to canonicalize capability file path", o.clone())?,
None => {
let capabilities_dir = dirs.tauri.join("capabilities");
capabilities_dir.join(format!(
"{}.{}",
capability.identifier,
options.format.extension()
))
}
};
if path.exists() {
let msg = format!(
"Capability already exists at {}",
dunce::simplified(&path).display()
);
let overwrite = prompts::confirm(&format!("{msg}, overwrite?"), Some(false))?;
if overwrite {
std::fs::remove_file(&path).fs_context("failed to remove capability file", path.clone())?;
} else {
crate::error::bail!(msg);
}
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).fs_context(
"failed to create capability directory",
parent.to_path_buf(),
)?;
}
std::fs::write(&path, options.format.serialize(&capability)?)
.fs_context("failed to write capability file", path.clone())?;
log::info!(action = "Created"; "capability at {}", dunce::simplified(&path).display());
Ok(())
}

View File

@@ -0,0 +1,42 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::error::Context;
use serde::Serialize;
use std::fmt::Display;
pub mod capability;
pub mod permission;
#[derive(Debug, clap::ValueEnum, Clone)]
enum FileFormat {
Json,
Toml,
}
impl Display for FileFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Json => write!(f, "json"),
Self::Toml => write!(f, "toml"),
}
}
}
impl FileFormat {
pub fn extension(&self) -> &'static str {
match self {
Self::Json => "json",
Self::Toml => "toml",
}
}
pub fn serialize<S: Serialize>(&self, s: &S) -> crate::Result<String> {
let contents = match self {
Self::Json => serde_json::to_string_pretty(s).context("failed to serialize JSON")?,
Self::Toml => toml_edit::ser::to_string_pretty(s).context("failed to serialize TOML")?,
};
Ok(contents)
}
}

View File

@@ -0,0 +1,282 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::path::Path;
use clap::Parser;
use crate::{
Result,
error::{Context, ErrorExt},
helpers::{app_paths::resolve_tauri_dir, prompts},
};
#[derive(Clone)]
enum TomlOrJson {
Toml(toml_edit::DocumentMut),
Json(serde_json::Value),
}
impl TomlOrJson {
fn identifier(&self) -> &str {
match self {
TomlOrJson::Toml(t) => t
.get("identifier")
.and_then(|k| k.as_str())
.unwrap_or_default(),
TomlOrJson::Json(j) => j
.get("identifier")
.and_then(|k| k.as_str())
.unwrap_or_default(),
}
}
fn platforms(&self) -> Option<Vec<&str>> {
match self {
TomlOrJson::Toml(t) => t.get("platforms").and_then(|k| {
k.as_array()
.and_then(|array| array.iter().map(|v| v.as_str()).collect())
}),
TomlOrJson::Json(j) => j.get("platforms").and_then(|k| {
if let Some(array) = k.as_array() {
let mut items = Vec::new();
for item in array {
if let Some(s) = item.as_str() {
items.push(s);
}
}
Some(items)
} else {
None
}
}),
}
}
fn insert_permission(&mut self, identifier: String) {
match self {
TomlOrJson::Toml(t) => {
let permissions = t.entry("permissions").or_insert_with(|| {
toml_edit::Item::Value(toml_edit::Value::Array(toml_edit::Array::new()))
});
if let Some(permissions) = permissions.as_array_mut() {
permissions.push(identifier)
};
}
TomlOrJson::Json(j) => {
if let Some(o) = j.as_object_mut() {
let permissions = o
.entry("permissions")
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
if let Some(permissions) = permissions.as_array_mut() {
permissions.push(serde_json::Value::String(identifier))
};
}
}
};
}
fn has_permission(&self, identifier: &str) -> bool {
(|| {
Some(match self {
TomlOrJson::Toml(t) => t
.get("permissions")?
.as_array()?
.iter()
.any(|value| value.as_str() == Some(identifier)),
TomlOrJson::Json(j) => j
.as_object()?
.get("permissions")?
.as_array()?
.iter()
.any(|value| value.as_str() == Some(identifier)),
})
})()
.unwrap_or_default()
}
fn to_string(&self) -> Result<String> {
Ok(match self {
TomlOrJson::Toml(t) => t.to_string(),
TomlOrJson::Json(j) => {
serde_json::to_string_pretty(&j).context("failed to serialize JSON")?
}
})
}
}
fn capability_from_path<P: AsRef<Path>>(path: P) -> Option<TomlOrJson> {
match path.as_ref().extension().and_then(|o| o.to_str()) {
Some("toml") => std::fs::read_to_string(&path)
.ok()
.and_then(|c| c.parse::<toml_edit::DocumentMut>().ok())
.map(TomlOrJson::Toml),
Some("json") => std::fs::read(&path)
.ok()
.and_then(|c| serde_json::from_slice::<serde_json::Value>(&c).ok())
.map(TomlOrJson::Json),
_ => None,
}
}
#[derive(Debug, Parser)]
#[clap(about = "Add a permission to capabilities")]
pub struct Options {
/// Permission to add.
pub identifier: String,
/// Capability to add the permission to.
pub capability: Option<String>,
}
pub fn command(options: Options) -> Result<()> {
let dir = match resolve_tauri_dir() {
Some(t) => t,
None => std::env::current_dir().context("failed to resolve current directory")?,
};
let capabilities_dir = dir.join("capabilities");
if !capabilities_dir.exists() {
crate::error::bail!(
"Couldn't find capabilities directory at {}",
dunce::simplified(&capabilities_dir).display()
);
}
let known_plugins = crate::helpers::plugins::known_plugins();
let known_plugin = options
.identifier
.split_once(':')
.and_then(|(plugin, _permission)| known_plugins.get(&plugin));
let capabilities_iter = std::fs::read_dir(&capabilities_dir)
.fs_context(
"failed to read capabilities directory",
capabilities_dir.clone(),
)?
.flatten()
.filter(|e| e.file_type().map(|e| e.is_file()).unwrap_or_default())
.filter_map(|e| {
let path = e.path();
capability_from_path(&path).and_then(|capability| match &options.capability {
Some(c) => (c == capability.identifier()).then_some((capability, path)),
None => Some((capability, path)),
})
});
let (desktop_only, mobile_only) = known_plugin
.map(|p| (p.desktop_only, p.mobile_only))
.unwrap_or_default();
let expected_capability_config = if desktop_only {
Some((
vec![
tauri_utils::platform::Target::MacOS.to_string(),
tauri_utils::platform::Target::Windows.to_string(),
tauri_utils::platform::Target::Linux.to_string(),
],
"desktop",
))
} else if mobile_only {
Some((
vec![
tauri_utils::platform::Target::Android.to_string(),
tauri_utils::platform::Target::Ios.to_string(),
],
"mobile",
))
} else {
None
};
let capabilities = if let Some((expected_platforms, target_name)) = expected_capability_config {
let mut capabilities = capabilities_iter
.filter(|(capability, _path)| {
capability.platforms().is_some_and(|platforms| {
// all platforms must be in the expected platforms list
platforms
.iter()
.all(|p| expected_platforms.contains(&p.to_string()))
})
})
.collect::<Vec<_>>();
if capabilities.is_empty() {
let identifier = format!("{target_name}-capability");
let capability_path = capabilities_dir.join(target_name).with_extension("json");
log::info!(
"Capability matching platforms {expected_platforms:?} not found, creating {}",
capability_path.display()
);
capabilities.push((
TomlOrJson::Json(serde_json::json!({
"identifier": identifier,
"platforms": expected_platforms,
"windows": ["main"]
})),
capability_path,
));
}
capabilities
} else {
capabilities_iter.collect::<Vec<_>>()
};
let mut capabilities = if capabilities.len() > 1 {
let selections = prompts::multiselect(
&format!(
"Choose which capabilities to add the permission `{}` to:",
options.identifier
),
capabilities
.iter()
.map(|(c, p)| {
let id = c.identifier();
if id.is_empty() {
dunce::simplified(p).to_str().unwrap_or_default()
} else {
id
}
})
.collect::<Vec<_>>()
.as_slice(),
None,
)?;
if selections.is_empty() {
crate::error::bail!("You did not select any capabilities to update");
}
selections
.into_iter()
.map(|idx| capabilities[idx].clone())
.collect()
} else {
capabilities
};
if capabilities.is_empty() {
crate::error::bail!("Could not find a capability to update");
}
for (capability, path) in &mut capabilities {
if capability.has_permission(&options.identifier) {
log::info!(
"Permission `{}` already found in `{}` at {}",
options.identifier,
capability.identifier(),
dunce::simplified(path).display()
);
} else {
capability.insert_permission(options.identifier.clone());
std::fs::write(&*path, capability.to_string()?)
.fs_context("failed to write capability file", path.clone())?;
log::info!(action = "Added"; "permission `{}` to `{}` at {}", options.identifier, capability.identifier(), dunce::simplified(path).display());
}
}
Ok(())
}

View File

@@ -0,0 +1,158 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use clap::Parser;
use crate::{
Result,
error::{Context, ErrorExt},
};
use colored::Colorize;
use tauri_utils::acl::{APP_ACL_KEY, manifest::Manifest};
use std::{collections::BTreeMap, fs::read_to_string};
#[derive(Debug, Parser)]
#[clap(about = "List permissions available to your application")]
pub struct Options {
/// Name of the plugin to list permissions.
plugin: Option<String>,
/// Permission identifier filter.
#[clap(short, long)]
filter: Option<String>,
}
pub fn command(options: Options) -> Result<()> {
let dirs = crate::helpers::app_paths::resolve_dirs();
let acl_manifests_path = dirs
.tauri
.join("gen")
.join("schemas")
.join("acl-manifests.json");
if acl_manifests_path.exists() {
let plugin_manifest_json = read_to_string(&acl_manifests_path)
.fs_context("failed to read plugin manifest", acl_manifests_path)?;
let acl = serde_json::from_str::<BTreeMap<String, Manifest>>(&plugin_manifest_json)
.context("failed to parse plugin manifest as JSON")?;
for (key, manifest) in acl {
if options
.plugin
.as_ref()
.map(|p| p != &key)
.unwrap_or_default()
{
continue;
}
let mut permissions = Vec::new();
let prefix = if key == APP_ACL_KEY {
"".to_string()
} else {
format!("{}:", key.magenta())
};
if let Some(default) = manifest.default_permission {
if options
.filter
.as_ref()
.map(|f| "default".contains(f))
.unwrap_or(true)
{
permissions.push(format!(
"{prefix}{}\n{}\nPermissions: {}",
"default".cyan(),
default.description,
default
.permissions
.iter()
.map(|c| c.cyan().to_string())
.collect::<Vec<_>>()
.join(", ")
));
}
}
for set in manifest.permission_sets.values() {
if options
.filter
.as_ref()
.map(|f| set.identifier.contains(f))
.unwrap_or(true)
{
permissions.push(format!(
"{prefix}{}\n{}\nPermissions: {}",
set.identifier.cyan(),
set.description,
set
.permissions
.iter()
.map(|c| c.cyan().to_string())
.collect::<Vec<_>>()
.join(", ")
));
}
}
for permission in manifest.permissions.into_values() {
if options
.filter
.as_ref()
.map(|f| permission.identifier.contains(f))
.unwrap_or(true)
{
permissions.push(format!(
"{prefix}{}{}{}{}",
permission.identifier.cyan(),
permission
.description
.map(|d| format!("\n{d}"))
.unwrap_or_default(),
if permission.commands.allow.is_empty() {
"".to_string()
} else {
format!(
"\n{}: {}",
"Allow commands".bold(),
permission
.commands
.allow
.iter()
.map(|c| c.green().to_string())
.collect::<Vec<_>>()
.join(", ")
)
},
if permission.commands.deny.is_empty() {
"".to_string()
} else {
format!(
"\n{}: {}",
"Deny commands".bold(),
permission
.commands
.deny
.iter()
.map(|c| c.red().to_string())
.collect::<Vec<_>>()
.join(", ")
)
},
));
}
}
if !permissions.is_empty() {
println!("{}\n", permissions.join("\n\n"));
}
}
Ok(())
} else {
crate::error::bail!("permission file not found, please build your application once first")
}
}

View File

@@ -0,0 +1,39 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use clap::{Parser, Subcommand};
use crate::Result;
pub mod add;
mod ls;
mod new;
pub mod rm;
#[derive(Debug, Parser)]
#[clap(about = "Manage or create permissions for your app or plugin")]
pub struct Cli {
#[clap(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
#[clap(alias = "create")]
New(new::Options),
Add(add::Options),
#[clap(alias = "remove")]
Rm(rm::Options),
#[clap(alias = "list")]
Ls(ls::Options),
}
pub fn command(cli: Cli) -> Result<()> {
match cli.command {
Commands::New(options) => new::command(options),
Commands::Add(options) => add::command(options),
Commands::Rm(options) => rm::command(options),
Commands::Ls(options) => ls::command(options),
}
}

View File

@@ -0,0 +1,124 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::path::PathBuf;
use clap::Parser;
use crate::{
Result,
acl::FileFormat,
error::{Context, ErrorExt},
helpers::{app_paths::resolve_tauri_dir, prompts},
};
use tauri_utils::acl::{Commands, Permission, manifest::PermissionFile};
#[derive(Debug, Parser)]
#[clap(about = "Create a new permission file")]
pub struct Options {
/// Permission identifier.
identifier: Option<String>,
/// Permission description
#[clap(long)]
description: Option<String>,
/// List of commands to allow
#[clap(short, long, value_delimiter = ',')]
allow: Option<Vec<String>>,
/// List of commands to deny
#[clap(short, long, value_delimiter = ',')]
deny: Option<Vec<String>>,
/// Output file format.
#[clap(long, default_value_t = FileFormat::Json)]
format: FileFormat,
/// The output file.
#[clap(short, long)]
out: Option<PathBuf>,
}
pub fn command(options: Options) -> Result<()> {
let identifier = match options.identifier {
Some(i) => i,
None => prompts::input("What's the permission identifier?", None, false, false)?.unwrap(),
};
let description = match options.description {
Some(d) => Some(d),
None => prompts::input::<String>("What's the permission description?", None, false, true)?
.filter(|d| !d.is_empty()),
};
let allow: Vec<String> = options
.allow
.map(FromIterator::from_iter)
.unwrap_or_default();
let deny: Vec<String> = options
.deny
.map(FromIterator::from_iter)
.unwrap_or_default();
let permission = Permission {
version: None,
identifier,
description,
commands: Commands { allow, deny },
scope: Default::default(),
platforms: Default::default(),
};
let path = match options.out {
Some(o) => o
.canonicalize()
.fs_context("failed to canonicalize permission file path", o.clone())?,
None => {
let dir = match resolve_tauri_dir() {
Some(t) => t,
None => std::env::current_dir().context("failed to resolve current directory")?,
};
let permissions_dir = dir.join("permissions");
permissions_dir.join(format!(
"{}.{}",
permission.identifier,
options.format.extension()
))
}
};
if path.exists() {
let msg = format!(
"Permission already exists at {}",
dunce::simplified(&path).display()
);
let overwrite = prompts::confirm(&format!("{msg}, overwrite?"), Some(false))?;
if overwrite {
std::fs::remove_file(&path).fs_context("failed to remove permission file", path.clone())?;
} else {
crate::error::bail!(msg);
}
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).fs_context(
"failed to create permission directory",
parent.to_path_buf(),
)?;
}
std::fs::write(
&path,
options
.format
.serialize(&PermissionFile {
default: None,
set: Vec::new(),
permission: vec![permission],
})
.context("failed to serialize permission")?,
)
.fs_context("failed to write permission file", path.clone())?;
log::info!(action = "Created"; "permission at {}", dunce::simplified(&path).display());
Ok(())
}

View File

@@ -0,0 +1,208 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::path::Path;
use clap::Parser;
use tauri_utils::acl::{PERMISSION_SCHEMA_FILE_NAME, manifest::PermissionFile};
use crate::{
Result,
acl::FileFormat,
error::{Context, ErrorExt},
helpers::app_paths::resolve_tauri_dir,
};
fn rm_permission_files(identifier: &str, dir: &Path) -> Result<()> {
for entry in std::fs::read_dir(dir)
.fs_context("failed to read permissions directory", dir.to_path_buf())?
.flatten()
{
let file_type = entry
.file_type()
.fs_context("failed to get permission file type", entry.path())?;
let path = entry.path();
if file_type.is_dir() {
rm_permission_files(identifier, &path)?;
} else {
if path
.file_name()
.map(|name| name == PERMISSION_SCHEMA_FILE_NAME)
.unwrap_or_default()
{
continue;
}
let (mut permission_file, format): (PermissionFile, FileFormat) =
match path.extension().and_then(|o| o.to_str()) {
Some("toml") => {
let content = std::fs::read_to_string(&path)
.fs_context("failed to read permission file", path.clone())?;
(
toml::from_str(&content).context("failed to deserialize permission file")?,
FileFormat::Toml,
)
}
Some("json") => {
let content =
std::fs::read(&path).fs_context("failed to read permission file", path.clone())?;
(
serde_json::from_slice(&content)
.context("failed to parse permission file as JSON")?,
FileFormat::Json,
)
}
_ => {
continue;
}
};
let mut updated;
if identifier == "default" {
updated = permission_file.default.is_some();
permission_file.default = None;
} else {
let set_len = permission_file.set.len();
permission_file
.set
.retain(|s| !identifier_match(identifier, &s.identifier));
updated = permission_file.set.len() != set_len;
let permission_len = permission_file.permission.len();
permission_file
.permission
.retain(|s| !identifier_match(identifier, &s.identifier));
updated = updated || permission_file.permission.len() != permission_len;
}
// if the file is empty, let's remove it
if permission_file.default.is_none()
&& permission_file.set.is_empty()
&& permission_file.permission.is_empty()
{
std::fs::remove_file(&path).fs_context("failed to remove permission file", path.clone())?;
log::info!(action = "Removed"; "file {}", dunce::simplified(&path).display());
} else if updated {
std::fs::write(
&path,
format
.serialize(&permission_file)
.context("failed to serialize permission")?,
)
.fs_context("failed to write permission file", path.clone())?;
log::info!(action = "Removed"; "permission {identifier} from {}", dunce::simplified(&path).display());
}
}
}
Ok(())
}
fn rm_permission_from_capabilities(identifier: &str, dir: &Path) -> Result<()> {
for entry in std::fs::read_dir(dir)
.fs_context("failed to read capabilities directory", dir.to_path_buf())?
.flatten()
{
let file_type = entry
.file_type()
.fs_context("failed to get capability file type", entry.path())?;
if !file_type.is_file() {
continue;
}
let path = entry.path();
match path.extension().and_then(|o| o.to_str()) {
Some("toml") => {
let content = std::fs::read_to_string(&path)
.fs_context("failed to read capability file", path.clone())?;
if let Ok(mut value) = content.parse::<toml_edit::DocumentMut>() {
if let Some(permissions) = value.get_mut("permissions").and_then(|p| p.as_array_mut()) {
let prev_len = permissions.len();
permissions.retain(|p| match p {
toml_edit::Value::String(s) => !identifier_match(identifier, s.value()),
toml_edit::Value::InlineTable(o) => {
if let Some(toml_edit::Value::String(permission_name)) = o.get("identifier") {
return !identifier_match(identifier, permission_name.value());
}
true
}
_ => false,
});
if prev_len != permissions.len() {
std::fs::write(&path, value.to_string())
.fs_context("failed to write capability file", path.clone())?;
log::info!(action = "Removed"; "permission from capability at {}", dunce::simplified(&path).display());
}
}
}
}
Some("json") => {
let content =
std::fs::read(&path).fs_context("failed to read capability file", path.clone())?;
if let Ok(mut value) = serde_json::from_slice::<serde_json::Value>(&content) {
if let Some(permissions) = value.get_mut("permissions").and_then(|p| p.as_array_mut()) {
let prev_len = permissions.len();
permissions.retain(|p| match p {
serde_json::Value::String(s) => !identifier_match(identifier, s),
serde_json::Value::Object(o) => {
if let Some(serde_json::Value::String(permission_name)) = o.get("identifier") {
return !identifier_match(identifier, permission_name);
}
true
}
_ => false,
});
if prev_len != permissions.len() {
std::fs::write(
&path,
serde_json::to_vec_pretty(&value).context("failed to serialize capability JSON")?,
)
.fs_context("failed to write capability file", path.clone())?;
log::info!(action = "Removed"; "permission from capability at {}", dunce::simplified(&path).display());
}
}
}
}
_ => {}
}
}
Ok(())
}
fn identifier_match(identifier: &str, permission: &str) -> bool {
match identifier.split_once(':') {
Some((plugin_name, "*")) => permission.contains(plugin_name),
_ => permission == identifier,
}
}
#[derive(Debug, Parser)]
#[clap(about = "Remove a permission file, and its reference from any capability")]
pub struct Options {
/// Permission to remove.
///
/// To remove all permissions for a given plugin, provide `<plugin-name>:*`
pub identifier: String,
}
pub fn command(options: Options) -> Result<()> {
let permissions_dir = std::env::current_dir()
.context("failed to resolve current directory")?
.join("permissions");
if permissions_dir.exists() {
rm_permission_files(&options.identifier, &permissions_dir)?;
}
if let Some(tauri_dir) = resolve_tauri_dir() {
let capabilities_dir = tauri_dir.join("capabilities");
if capabilities_dir.exists() {
rm_permission_from_capabilities(&options.identifier, &capabilities_dir)?;
}
}
Ok(())
}

328
crates/tauri-cli/src/add.rs Normal file
View File

@@ -0,0 +1,328 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use clap::Parser;
use colored::Colorize;
use regex::Regex;
use crate::{
Result, acl,
error::{Context, ErrorExt},
helpers::{
app_paths::{Dirs, resolve_frontend_dir},
cargo,
config::Target,
npm::PackageManager,
},
};
use serde_json::Value as JsonValue;
use std::{fs, path::Path, process::Command};
#[derive(Debug, Parser)]
#[clap(about = "Add a tauri plugin to the project")]
pub struct Options {
/// The plugin to add.
pub plugin: String,
/// Git tag to use.
#[clap(short, long)]
pub tag: Option<String>,
/// Git rev to use.
#[clap(short, long)]
pub rev: Option<String>,
/// Git branch to use.
#[clap(short, long)]
pub branch: Option<String>,
/// Don't format code with rustfmt
#[clap(long)]
pub no_fmt: bool,
}
pub fn command(options: Options) -> Result<()> {
let dirs = crate::helpers::app_paths::resolve_dirs();
run(options, &dirs)
}
pub fn run(options: Options, dirs: &Dirs) -> Result<()> {
let (plugin, version) = options
.plugin
.split_once('@')
.map(|(p, v)| (p, Some(v)))
.unwrap_or((&options.plugin, None));
let mut plugins = crate::helpers::plugins::known_plugins();
let (metadata, is_known) = plugins
.remove(plugin)
.map(|metadata| (metadata, true))
.unwrap_or_default();
let plugin_snake_case = plugin.replace('-', "_");
let crate_name = format!("tauri-plugin-{plugin}");
let npm_name = if is_known {
format!("@tauri-apps/plugin-{plugin}")
} else {
format!("tauri-plugin-{plugin}-api")
};
if !is_known && (options.tag.is_some() || options.rev.is_some() || options.branch.is_some()) {
crate::error::bail!(
"Git options --tag, --rev and --branch can only be used with official Tauri plugins"
);
}
let frontend_dir = resolve_frontend_dir();
let target_str = metadata
.desktop_only
.then_some(r#"cfg(not(any(target_os = "android", target_os = "ios")))"#)
.or_else(|| {
metadata
.mobile_only
.then_some(r#"cfg(any(target_os = "android", target_os = "ios"))"#)
});
let cargo_version_req = version.or(metadata.version_req.as_deref());
cargo::install_one(cargo::CargoInstallOptions {
name: &crate_name,
version: cargo_version_req,
branch: options.branch.as_deref(),
rev: options.rev.as_deref(),
tag: options.tag.as_deref(),
cwd: Some(dirs.tauri),
target: target_str,
})?;
if !metadata.rust_only {
if let Some(manager) = frontend_dir.map(PackageManager::from_project) {
let npm_version_req = version
.map(ToString::to_string)
.or(metadata.version_req.as_ref().map(|v| match manager {
PackageManager::Npm => format!(">={v}"),
_ => format!("~{v}"),
}));
let npm_spec = match (npm_version_req, options.tag, options.rev, options.branch) {
(Some(version_req), _, _, _) => format!("{npm_name}@{version_req}"),
(None, Some(tag), None, None) => {
format!("tauri-apps/tauri-plugin-{plugin}#{tag}")
}
(None, None, Some(rev), None) => {
format!("tauri-apps/tauri-plugin-{plugin}#{rev}")
}
(None, None, None, Some(branch)) => {
format!("tauri-apps/tauri-plugin-{plugin}#{branch}")
}
(None, None, None, None) => npm_name,
_ => crate::error::bail!("Only one of --tag, --rev and --branch can be specified"),
};
manager.install(&[npm_spec], dirs.frontend)?;
}
let _ = acl::permission::add::command(acl::permission::add::Options {
identifier: format!("{plugin}:default"),
capability: None,
});
}
if plugin == "updater" {
// not fatal: the plugin is installed by this point, and the developer can set the option by
// hand if we could not
if let Err(e) = enable_require_signed_version(dirs.tauri) {
log::warn!(
"Failed to enable `plugins > updater > requireSignedVersion`, set it manually: {e}"
);
}
}
// add plugin init code to main.rs or lib.rs
let plugin_init_fn = if plugin == "stronghold" {
"Builder::new(|pass| todo!()).build()"
} else if plugin == "localhost" {
"Builder::new(todo!()).build()"
} else if plugin == "single-instance" {
"init(|app, args, cwd| {})"
} else if plugin == "log" {
"Builder::new().level(tauri_plugin_log::log::LevelFilter::Info).build()"
} else if metadata.builder {
"Builder::new().build()"
} else {
"init()"
};
let plugin_init = format!(".plugin(tauri_plugin_{plugin_snake_case}::{plugin_init_fn})");
let re = Regex::new(r"(tauri\s*::\s*Builder\s*::\s*default\(\))(\s*)").unwrap();
for file in [
dirs.tauri.join("src/main.rs"),
dirs.tauri.join("src/lib.rs"),
] {
let contents =
std::fs::read_to_string(&file).fs_context("failed to read Rust entry point", file.clone())?;
if contents.contains(&plugin_init) {
log::info!(
"Plugin initialization code already found on {}",
file.display()
);
return Ok(());
}
if re.is_match(&contents) {
let out = re.replace(&contents, format!("$1$2{plugin_init}$2"));
log::info!("Adding plugin to {}", file.display());
std::fs::write(&file, out.as_bytes()).fs_context("failed to write plugin init code", file)?;
if !options.no_fmt {
// reformat code with rustfmt
log::info!("Running `cargo fmt`...");
let _ = Command::new("cargo")
.arg("fmt")
.current_dir(dirs.tauri)
.status();
}
return Ok(());
}
}
let builder_code = if metadata.builder {
format!(r#"+ .plugin(tauri_plugin_{plugin_snake_case}::Builder::new().build())"#,)
} else {
format!(r#"+ .plugin(tauri_plugin_{plugin_snake_case}::init())"#)
};
let rust_code = format!(
r#" {}
{}
{}"#,
"tauri::Builder::default()".dimmed(),
builder_code.normal().green(),
r#".invoke_handler(tauri::generate_handler![])
.run(tauri::generate_context!())
.expect("error while running tauri application");"#
.dimmed(),
);
log::warn!(
"Couldn't find `{}` in `{}` or `{}`, you must enable the plugin in your Rust code manually:\n\n{}",
"tauri::Builder".cyan(),
"main.rs".cyan(),
"lib.rs".cyan(),
rust_code
);
Ok(())
}
/// Turns on the updater's signed version check for a project adopting the plugin now.
///
/// An update endpoint response is not signed, so the version it announces does not by itself
/// prove which release its `url` and `signature` point at. `requireSignedVersion` makes the
/// updater compare that version against the one recorded inside the signature, which is what
/// stops a tampered response from pairing a new version number with an older release to force a
/// downgrade.
///
/// This can only be the default for a project adding the plugin now, because the check also
/// rejects releases that were signed before the version was recorded. A project already serving
/// such releases has to re-sign them first, so a value that is already in the config is left
/// alone.
fn enable_require_signed_version(tauri_dir: &Path) -> Result<()> {
let (mut config, config_path) =
tauri_utils::config::parse::parse_value(Target::current(), tauri_dir.join("tauri.conf.json"))
.context("failed to parse config")?;
if !set_require_signed_version(&mut config) {
return Ok(());
}
let serialized = if config_path.extension().is_some_and(|ext| ext == "toml") {
toml::to_string_pretty(&config).context("failed to serialize config")?
} else {
serde_json::to_string_pretty(&config).context("failed to serialize config")?
};
fs::write(&config_path, serialized).fs_context("failed to write config", config_path.clone())?;
log::info!(
"Enabled `plugins > updater > requireSignedVersion` in {}",
config_path.display()
);
log::info!("Remember to set `plugins > updater > pubkey` and `endpoints`.");
Ok(())
}
/// Sets `plugins > updater > requireSignedVersion`, returning whether the config changed.
fn set_require_signed_version(config: &mut JsonValue) -> bool {
let Some(root) = config.as_object_mut() else {
return false;
};
let plugins = root
.entry("plugins")
.or_insert_with(|| JsonValue::Object(Default::default()));
let Some(plugins) = plugins.as_object_mut() else {
return false;
};
let updater = plugins
.entry("updater")
.or_insert_with(|| JsonValue::Object(Default::default()));
let Some(updater) = updater.as_object_mut() else {
return false;
};
// a value already in the config is a deliberate choice, an explicit `false` included
if updater.contains_key("requireSignedVersion") || updater.contains_key("require-signed-version")
{
return false;
}
updater.insert("requireSignedVersion".into(), JsonValue::Bool(true));
true
}
#[cfg(test)]
mod tests {
use super::set_require_signed_version;
use serde_json::json;
#[test]
fn enables_required_version_signing_on_a_config_without_updater_plugin_config() {
let mut config = json!({ "identifier": "com.tauri.dev" });
assert!(set_require_signed_version(&mut config));
assert_eq!(config["plugins"]["updater"]["requireSignedVersion"], true);
// the rest of the config is carried through untouched
assert_eq!(config["identifier"], "com.tauri.dev");
}
#[test]
fn keeps_the_existing_updater_config() {
let mut config = json!({ "plugins": { "updater": { "pubkey": "abc" } } });
assert!(set_require_signed_version(&mut config));
assert_eq!(config["plugins"]["updater"]["pubkey"], "abc");
assert_eq!(config["plugins"]["updater"]["requireSignedVersion"], true);
}
#[test]
fn never_overwrites_a_deliberate_choice() {
// a project that still serves releases signed before the version was recorded has to keep
// this off until it has re-signed them, so an explicit `false` must survive
for existing in ["requireSignedVersion", "require-signed-version"] {
let mut config = json!({ "plugins": { "updater": { existing: false } } });
assert!(!set_require_signed_version(&mut config));
assert_eq!(config["plugins"]["updater"][existing], false);
assert!(
config["plugins"]["updater"]
.as_object()
.is_some_and(|updater| updater.len() == 1)
);
}
}
#[test]
fn leaves_a_config_it_cannot_understand_alone() {
let mut config = json!({ "plugins": { "updater": "not an object" } });
assert!(!set_require_signed_version(&mut config));
assert_eq!(config["plugins"]["updater"], "not an object");
}
}

View File

@@ -0,0 +1,279 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::{
ConfigValue, Result,
bundle::BundleFormat,
error::{Context, ErrorExt},
helpers::{
self,
app_paths::Dirs,
config::{ConfigMetadata, FrontendDist, get_config},
},
info::plugins::check_mismatched_packages,
interface::{AppInterface, rust::get_cargo_target_dir},
};
use clap::{ArgAction, Parser};
use std::env::set_current_dir;
use tauri_utils::config::RunnerConfig;
use tauri_utils::platform::Target;
#[derive(Debug, Clone, Parser)]
#[clap(
about = "Build your app in release mode and generate bundles and installers",
long_about = "Build your app in release mode and generate bundles and installers. It makes use of the `build.frontendDist` property from your `tauri.conf.json` file. It also runs your `build.beforeBuildCommand` which usually builds your frontend into `build.frontendDist`. This will also run `build.beforeBundleCommand` before generating the bundles and installers of your app."
)]
pub struct Options {
/// Binary to use to build the application, defaults to `cargo`
#[clap(short, long)]
pub runner: Option<RunnerConfig>,
/// Builds with the debug flag
#[clap(short, long)]
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)]
pub target: Option<String>,
/// Space or comma separated list of features to activate
#[clap(short, long, action = ArgAction::Append, num_args(0..), value_delimiter = ',')]
pub features: Vec<String>,
/// Space or comma separated list of bundles to package.
#[clap(short, long, action = ArgAction::Append, num_args(0..), value_delimiter = ',')]
pub bundles: Option<Vec<BundleFormat>>,
/// Skip the bundling step even if `bundle > active` is `true` in tauri config.
#[clap(long)]
pub no_bundle: 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>,
/// Command line arguments passed to the runner. Use `--` to explicitly mark the start of the arguments.
pub args: Vec<String>,
/// Skip prompting for values
#[clap(long, env = "CI")]
pub ci: bool,
/// Skip stapling the notarization ticket onto the app and do not wait for notarization to finish.
///
/// Gatekeeper will look for stapled tickets to tell whether your app was notarized without
/// reaching out to Apple's servers which is helpful in offline environments.
///
/// Enabling this option will also result in `tauri build` not waiting for notarization to finish
/// which is helpful for the very first time your app is notarized as this can take multiple hours.
/// On subsequent runs, it's recommended to disable this setting again.
#[clap(long)]
pub skip_stapling: bool,
/// Do not error out if a version mismatch is detected on a Tauri package.
///
/// Only use this when you are sure the mismatch is incorrectly detected as version mismatched Tauri packages can lead to unknown behavior.
#[clap(long)]
pub ignore_version_mismatches: bool,
/// Skip code signing when bundling the app
#[clap(long)]
pub no_sign: bool,
/// Skip patching the main executable with bundle type information.
///
/// The patching rewrites the binary in place, invalidating an existing code
/// signature. Skipping it preserves an already-signed binary at the cost of
/// per-bundle-type updater support (only relevant when shipping multiple
/// bundle types per platform).
#[clap(long)]
pub no_binary_patching: bool,
}
pub fn command(mut options: Options, verbosity: u8) -> Result<()> {
let dirs = crate::helpers::app_paths::resolve_dirs();
if options.no_sign {
log::warn!("--no-sign flag detected: Signing will be skipped.");
}
let ci = options.ci;
let target = options
.target
.as_deref()
.map(Target::from_triple)
.unwrap_or_else(Target::current);
let config = get_config(
target,
&options.config.iter().map(|c| &c.0).collect::<Vec<_>>(),
dirs.tauri,
)?;
let mut interface = AppInterface::new(&config, options.target.clone(), dirs.tauri)?;
setup(&interface, &mut options, &config, &dirs, false)?;
if let Some(minimum_system_version) = &config.bundle.macos.minimum_system_version {
unsafe { std::env::set_var("MACOSX_DEPLOYMENT_TARGET", minimum_system_version) };
}
let app_settings = interface.app_settings();
let interface_options = options.clone().into();
let out_dir = app_settings.out_dir(&interface_options, dirs.tauri)?;
let bin_path = interface.build(interface_options, &dirs)?;
log::info!(action = "Built"; "application at: {}", tauri_utils::display_path(bin_path));
let app_settings = interface.app_settings();
if !options.no_bundle && (config.bundle.active || options.bundles.is_some()) {
crate::bundle::bundle(
&options.into(),
verbosity,
ci,
&interface,
&*app_settings,
&config,
&dirs,
&out_dir,
)?;
}
Ok(())
}
pub fn setup(
interface: &AppInterface,
options: &mut Options,
config: &ConfigMetadata,
dirs: &Dirs,
mobile: bool,
) -> Result<()> {
// TODO: Maybe optimize this to run in parallel in the future
// see https://github.com/tauri-apps/tauri/pull/13993#discussion_r2280697117
log::info!("Looking up installed tauri packages to check mismatched versions...");
if let Err(error) = check_mismatched_packages(dirs.frontend, dirs.tauri) {
if options.ignore_version_mismatches {
log::error!("{error}");
} else {
return Err(error);
}
}
set_current_dir(dirs.tauri).context("failed to set current directory")?;
let bundle_identifier_source = config
.find_bundle_identifier_overwriter()
.unwrap_or_else(|| "tauri.conf.json".into());
if config.identifier == "com.tauri.dev" {
crate::error::bail!(
"You must change the bundle identifier in `{bundle_identifier_source} identifier`. The default value `com.tauri.dev` is not allowed as it must be unique across applications.",
);
}
if config
.identifier
.chars()
.any(|ch| !(ch.is_alphanumeric() || ch == '-' || ch == '.'))
{
crate::error::bail!(
"The bundle identifier \"{}\" set in `{bundle_identifier_source:?} identifier`. The bundle identifier string must contain only alphanumeric characters (A-Z, a-z, and 0-9), hyphens (-), and periods (.).",
config.identifier,
);
}
if config.identifier.ends_with(".app") {
log::warn!(
"The bundle identifier \"{}\" set in `{bundle_identifier_source:?} identifier` ends with `.app`. This is not recommended because it conflicts with the application bundle extension on macOS.",
config.identifier,
);
}
if config.product_name.as_deref() == Some("tauri-app") {
log::warn!(
"The `productName` is still set to the default value `tauri-app`, it must be unique across applications since it is written into install paths and platform metadata that are expected to be unique to your application, like the Windows installer upgrade code."
);
}
if let Some(before_build) = config.build.before_build_command.clone() {
helpers::run_hook(
"beforeBuildCommand",
before_build,
interface,
options.debug,
dirs.frontend,
)?;
}
if let Some(FrontendDist::Directory(web_asset_path)) = &config.build.frontend_dist {
if !web_asset_path.exists() {
let absolute_path = web_asset_path
.parent()
.and_then(|p| p.canonicalize().ok())
.map(|p| p.join(web_asset_path.file_name().unwrap()))
.unwrap_or_else(|| std::env::current_dir().unwrap().join(web_asset_path));
crate::error::bail!(
"Unable to find your web assets, did you forget to build your web app? Your frontendDist is set to \"{}\" (which is `{}`).",
web_asset_path.display(),
absolute_path.display(),
);
}
if web_asset_path
.canonicalize()
.fs_context("failed to canonicalize path", web_asset_path.to_path_buf())?
.file_name()
== Some(std::ffi::OsStr::new("src-tauri"))
{
crate::error::bail!(
"The configured frontendDist is the `src-tauri` folder. Please isolate your web assets on a separate folder and update `tauri.conf.json > build > frontendDist`.",
);
}
// Issue #13287 - Allow the use of target dir inside frontendDist/distDir
// https://github.com/tauri-apps/tauri/issues/13287
let target_path = get_cargo_target_dir(&options.args, dirs.tauri)?;
let mut out_folders = Vec::new();
if let Ok(web_asset_canonical) = dunce::canonicalize(web_asset_path) {
if let Ok(relative_path) = target_path.strip_prefix(&web_asset_canonical) {
let relative_str = relative_path.to_string_lossy();
if !relative_str.is_empty() {
out_folders.push(relative_str.to_string());
}
}
for folder in &["node_modules", "src-tauri"] {
let sub_path = web_asset_canonical.join(folder);
if sub_path.is_dir() {
out_folders.push(folder.to_string());
}
}
}
if !out_folders.is_empty() {
crate::error::bail!(
"The configured frontendDist includes the `{:?}` {}. Please isolate your web assets on a separate folder and update `tauri.conf.json > build > frontendDist`.",
out_folders,
if out_folders.len() == 1 {
"folder"
} else {
"folders"
}
);
}
}
if options.runner.is_none() {
options.runner.clone_from(&config.build.runner);
}
options
.features
.extend_from_slice(config.build.features.as_deref().unwrap_or_default());
interface.build_options(&mut options.args, &mut options.features, mobile);
Ok(())
}

View File

@@ -0,0 +1,350 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::{
path::{Path, PathBuf},
str::FromStr,
sync::OnceLock,
};
use clap::{ArgAction, Parser, ValueEnum, builder::PossibleValue};
use tauri_bundler::PackageType;
use tauri_utils::platform::Target;
use crate::{
ConfigValue,
error::{Context, ErrorExt},
helpers::{
self,
app_paths::Dirs,
config::{ConfigMetadata, get_config},
updater_signature,
},
interface::{AppInterface, AppSettings},
};
#[derive(Debug, Clone)]
pub struct BundleFormat(PackageType);
impl FromStr for BundleFormat {
type Err = crate::Error;
fn from_str(s: &str) -> crate::Result<Self> {
PackageType::from_short_name(s)
.map(Self)
.with_context(|| format!("unknown bundle format {s}"))
}
}
impl ValueEnum for BundleFormat {
fn value_variants<'a>() -> &'a [Self] {
static VARIANTS: OnceLock<Vec<BundleFormat>> = OnceLock::new();
VARIANTS.get_or_init(|| PackageType::all().iter().map(|t| Self(*t)).collect())
}
fn to_possible_value(&self) -> Option<PossibleValue> {
let hide = (!cfg!(windows) && self.0 == PackageType::Nsis) || self.0 == PackageType::Updater;
Some(PossibleValue::new(self.0.short_name()).hide(hide))
}
}
#[derive(Debug, Parser, Clone)]
#[clap(
about = "Generate bundles and installers for your app (already built by `tauri build`)",
long_about = "Generate bundles and installers for your app (already built by `tauri build`). This run `build.beforeBundleCommand` before generating the bundles and installers of your app."
)]
pub struct Options {
/// Builds with the debug flag
#[clap(short, long)]
pub debug: bool,
/// Space or comma separated list of bundles to package.
#[clap(short, long, action = ArgAction::Append, num_args(0..), value_delimiter = ',')]
pub bundles: Option<Vec<BundleFormat>>,
/// 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>,
/// Space or comma separated list of features, should be the same features passed to `tauri build` if any.
#[clap(short, long, action = ArgAction::Append, num_args(0..), value_delimiter = ',')]
pub features: Vec<String>,
/// 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)]
pub target: Option<String>,
/// Skip prompting for values
#[clap(long, env = "CI")]
pub ci: bool,
/// Skip stapling the notarization ticket onto the app and do not wait for notarization to finish.
///
/// Gatekeeper will look for stapled tickets to tell whether your app was notarized without
/// reaching out to Apple's servers which is helpful in offline environments.
///
/// Enabling this option will also result in `tauri build` not waiting for notarization to finish
/// which is helpful for the very first time your app is notarized as this can take multiple hours.
/// On subsequent runs, it's recommended to disable this setting again.
#[clap(long)]
pub skip_stapling: bool,
/// Skip code signing during the build or bundling process.
///
/// Useful for local development and CI environments
/// where signing certificates or environment variables
/// are not available or not needed.
#[clap(long)]
pub no_sign: bool,
/// Skip patching the main executable with bundle type information.
///
/// The patching rewrites the binary in place, invalidating an existing code
/// signature. Skipping it preserves an already-signed binary at the cost of
/// per-bundle-type updater support (only relevant when shipping multiple
/// bundle types per platform).
#[clap(long)]
pub no_binary_patching: bool,
}
impl From<crate::build::Options> for Options {
fn from(value: crate::build::Options) -> Self {
Self {
bundles: value.bundles,
target: value.target,
features: value.features,
debug: value.debug,
ci: value.ci,
config: value.config,
skip_stapling: value.skip_stapling,
no_sign: value.no_sign,
no_binary_patching: value.no_binary_patching,
}
}
}
pub fn command(options: Options, verbosity: u8) -> crate::Result<()> {
let dirs = crate::helpers::app_paths::resolve_dirs();
let ci = options.ci;
let target = options
.target
.as_deref()
.map(Target::from_triple)
.unwrap_or_else(Target::current);
let config = get_config(
target,
&options.config.iter().map(|c| &c.0).collect::<Vec<_>>(),
dirs.tauri,
)?;
let interface = AppInterface::new(&config, options.target.clone(), dirs.tauri)?;
std::env::set_current_dir(dirs.tauri).context("failed to set current directory")?;
if let Some(minimum_system_version) = &config.bundle.macos.minimum_system_version {
unsafe { std::env::set_var("MACOSX_DEPLOYMENT_TARGET", minimum_system_version) };
}
let app_settings = interface.app_settings();
let interface_options = options.clone().into();
let out_dir = app_settings.out_dir(&interface_options, dirs.tauri)?;
bundle(
&options,
verbosity,
ci,
&interface,
&*app_settings,
&config,
&dirs,
&out_dir,
)
}
#[allow(clippy::too_many_arguments)]
pub fn bundle<A: AppSettings>(
options: &Options,
verbosity: u8,
ci: bool,
interface: &AppInterface,
app_settings: &A,
config: &ConfigMetadata,
dirs: &Dirs,
out_dir: &Path,
) -> crate::Result<()> {
let package_types: Vec<PackageType> = if let Some(bundles) = &options.bundles {
bundles.iter().map(|bundle| bundle.0).collect::<Vec<_>>()
} else {
config
.bundle
.targets
.to_vec()
.into_iter()
.map(Into::into)
.collect()
};
if package_types.is_empty() {
return Ok(());
}
// if we have a package to bundle, let's run the `before_bundle_command`.
if !package_types.is_empty() {
if let Some(before_bundle) = config.build.before_bundle_command.clone() {
helpers::run_hook(
"beforeBundleCommand",
before_bundle,
interface,
options.debug,
dirs.frontend,
)?;
}
}
let mut settings = app_settings
.get_bundler_settings(
options.clone().into(),
config,
out_dir,
package_types,
dirs.tauri,
)
.context("failed to build bundler settings")?;
settings.set_no_sign(options.no_sign);
settings.set_binary_patching(!options.no_binary_patching);
settings.set_log_level(match verbosity {
0 => log::Level::Error,
1 => log::Level::Info,
_ => log::Level::Trace,
});
let bundles = tauri_bundler::bundle_project(&settings)?;
sign_updaters(settings, bundles, ci)?;
Ok(())
}
fn sign_updaters(
settings: tauri_bundler::Settings,
bundles: Vec<tauri_bundler::Bundle>,
ci: bool,
) -> crate::Result<()> {
let Some(update_settings) = settings.updater() else {
// Updater not enabled
return Ok(());
};
let update_enabled_bundles: Vec<&tauri_bundler::Bundle> = bundles
.iter()
.filter(|bundle| {
matches!(
bundle.package_type,
PackageType::Updater
| PackageType::Nsis
| PackageType::WindowsMsi
| PackageType::AppImage
| PackageType::Deb
| PackageType::Rpm
)
})
.collect();
if update_enabled_bundles.is_empty() {
return Ok(());
}
if settings.no_sign() {
log::warn!("Updater signing is skipped due to --no-sign flag.");
return Ok(());
}
// get the public key
let pubkey = &update_settings.pubkey;
// check if pubkey points to a file...
let maybe_path = Path::new(pubkey);
let pubkey = if maybe_path.exists() {
std::fs::read_to_string(maybe_path)
.fs_context("failed to read pubkey from file", maybe_path.to_path_buf())?
} else {
pubkey.to_string()
};
// if no password provided we use an empty string
let password = std::env::var("TAURI_SIGNING_PRIVATE_KEY_PASSWORD")
.ok()
.or_else(|| if ci { Some("".into()) } else { None });
// get the private key
let private_key = std::env::var("TAURI_SIGNING_PRIVATE_KEY")
.ok()
.context("A public key has been found, but no private key. Make sure to set `TAURI_SIGNING_PRIVATE_KEY` environment variable.")?;
// check if private_key points to a file...
let maybe_path = Path::new(&private_key);
let private_key = if maybe_path.exists() {
std::fs::read_to_string(maybe_path).fs_context(
"failed to read private key from file",
maybe_path.to_path_buf(),
)?
} else {
private_key
};
if password.is_none() {
log::info!("Decrypting updater signing key, expect a prompt for password")
}
let secret_key =
updater_signature::secret_key(private_key, password).context("failed to decode secret key")?;
let public_key = updater_signature::pub_key(pubkey).context("failed to decode pubkey")?;
let mut signed_paths = Vec::new();
for bundle in update_enabled_bundles {
// we expect to have only one path in the vec but we iter if we add
// another type of updater package who require multiple file signature
for path in &bundle.bundle_paths {
// sign our path from environment variables
let (signature_path, signature) =
updater_signature::sign_file(&secret_key, path, Some(settings.version_string()))?;
if signature.keynum() != public_key.keynum() {
log::warn!(
"The updater secret key from `TAURI_SIGNING_PRIVATE_KEY` does not match the public key from `plugins > updater > pubkey`. If you are not rotating keys, this means your configuration is wrong and won't be accepted at runtime when performing update."
);
}
signed_paths.push(signature_path);
}
}
print_signed_updater_archive(&signed_paths)?;
Ok(())
}
fn print_signed_updater_archive(output_paths: &[PathBuf]) -> crate::Result<()> {
use std::fmt::Write;
if !output_paths.is_empty() {
let finished_bundles = output_paths.len();
let pluralised = if finished_bundles == 1 {
"updater signature"
} else {
"updater signatures"
};
let mut printable_paths = String::new();
for path in output_paths {
let _ = writeln!(
printable_paths,
" {}",
tauri_utils::display_path(path)
);
}
log::info!( action = "Finished"; "{finished_bundles} {pluralised} at:\n{printable_paths}");
}
Ok(())
}

View File

@@ -0,0 +1,103 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::{Result, error::ErrorExt};
use clap::{Command, Parser};
use clap_complete::{Shell, generate};
use std::{fs::write, path::PathBuf};
const PKG_MANAGERS: &[&str] = &["cargo", "pnpm", "npm", "yarn", "bun", "deno"];
#[derive(Debug, Clone, Parser)]
#[clap(about = "Generate Tauri CLI shell completions for Bash, Zsh, PowerShell or Fish")]
pub struct Options {
/// Shell to generate a completion script for.
#[clap(short, long, verbatim_doc_comment)]
shell: Shell,
/// Output file for the shell completions. By default the completions are printed to stdout.
#[clap(short, long)]
output: Option<PathBuf>,
}
fn completions_for(shell: Shell, manager: &'static str, cmd: Command) -> Vec<u8> {
let tauri = cmd.name("tauri");
let mut command = if manager == "npm" || manager == "bun" {
Command::new(manager)
.bin_name(manager)
.subcommand(Command::new("run").subcommand(tauri))
} else if manager == "deno" {
Command::new(manager)
.bin_name(manager)
.subcommand(Command::new("task").subcommand(tauri))
} else {
Command::new(manager).bin_name(manager).subcommand(tauri)
};
let mut buf = Vec::new();
generate(shell, &mut command, manager, &mut buf);
buf
}
fn get_completions(shell: Shell, cmd: Command) -> Result<String> {
let completions = if shell == Shell::Bash {
let mut completions =
String::from_utf8_lossy(&completions_for(shell, "cargo", cmd)).into_owned();
for &manager in PKG_MANAGERS {
completions.push_str(&format!(
"complete -F _cargo -o bashdefault -o default {} tauri\n",
if manager == "npm" {
"npm run"
} else if manager == "bun" {
"bun run"
} else if manager == "deno" {
"deno task"
} else {
manager
}
));
}
completions
} else {
let mut buffer = String::new();
for (i, manager) in PKG_MANAGERS.iter().enumerate() {
let buf = String::from_utf8_lossy(&completions_for(shell, manager, cmd.clone())).into_owned();
let completions = match shell {
Shell::PowerShell => {
if i != 0 {
// namespaces have already been imported
buf
.replace("using namespace System.Management.Automation.Language", "")
.replace("using namespace System.Management.Automation", "")
} else {
buf
}
}
_ => buf,
};
buffer.push_str(&completions);
buffer.push('\n');
}
buffer
};
Ok(completions)
}
pub fn command(options: Options, cmd: Command) -> Result<()> {
log::info!("Generating completion file for {}...", options.shell);
let completions = get_completions(options.shell, cmd)?;
if let Some(output) = options.output {
write(&output, completions).fs_context("failed to write to completions", output)?;
} else {
print!("{completions}");
}
Ok(())
}

374
crates/tauri-cli/src/dev.rs Normal file
View File

@@ -0,0 +1,374 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::{
CommandExt, ConfigValue, Error, Result,
error::{Context, ErrorExt},
helpers::{
app_paths::Dirs,
command_env,
config::{BeforeDevCommand, ConfigMetadata, FrontendDist, get_config, reload_config},
},
info::plugins::check_mismatched_packages,
interface::{AppInterface, ExitReason},
};
use clap::{ArgAction, Parser};
use shared_child::SharedChild;
use tauri_utils::{config::RunnerConfig, platform::Target};
use std::{
env::set_current_dir,
net::{IpAddr, Ipv4Addr},
path::PathBuf,
process::{Command, Stdio, exit},
sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
},
};
mod builtin_dev_server;
static BEFORE_DEV: OnceLock<SharedChild> = OnceLock::new();
static KILL_BEFORE_DEV_FLAG: AtomicBool = AtomicBool::new(false);
#[cfg(unix)]
const KILL_CHILDREN_SCRIPT: &[u8] = include_bytes!("../scripts/kill-children.sh");
pub const TAURI_CLI_BUILTIN_WATCHER_IGNORE_FILE: &[u8] =
include_bytes!("../tauri-dev-watcher.gitignore");
#[derive(Debug, Clone, Parser)]
#[clap(
about = "Run your app in development mode",
long_about = "Run your app in development mode 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.",
trailing_var_arg(true)
)]
pub struct Options {
/// Binary to use to run the application
#[clap(short, long)]
pub runner: Option<RunnerConfig>,
/// Target triple to build against
#[clap(short, long)]
pub target: Option<String>,
/// 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)]
pub 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,
/// Command line arguments passed to the runner.
/// Use `--` to explicitly mark the start of the arguments. Arguments after a second `--` are passed to the application
/// e.g. `tauri dev -- [runnerArgs] -- [appArgs]`.
pub args: Vec<String>,
/// 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>,
/// 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>,
#[clap(skip)]
pub host: Option<IpAddr>,
}
pub fn command(options: Options) -> Result<()> {
let dirs = crate::helpers::app_paths::resolve_dirs();
let r = command_internal(options, dirs);
if r.is_err() {
kill_before_dev_process();
}
r
}
fn command_internal(mut options: Options, dirs: Dirs) -> Result<()> {
let target = options
.target
.as_deref()
.map(Target::from_triple)
.unwrap_or_else(Target::current);
let mut config = get_config(
target,
&options.config.iter().map(|c| &c.0).collect::<Vec<_>>(),
dirs.tauri,
)?;
let mut interface = AppInterface::new(&config, options.target.clone(), dirs.tauri)?;
setup(&interface, &mut options, &mut config, &dirs)?;
let exit_on_panic = options.exit_on_panic;
let no_watch = options.no_watch;
interface.dev(
&mut config,
options.into(),
move |status, reason| on_app_exit(status, reason, exit_on_panic, no_watch),
&dirs,
)
}
pub fn setup(
interface: &AppInterface,
options: &mut Options,
config: &mut ConfigMetadata,
dirs: &Dirs,
) -> Result<()> {
std::thread::spawn(|| {
if let Err(error) = check_mismatched_packages(dirs.frontend, dirs.tauri) {
log::error!("{error}");
}
});
set_current_dir(dirs.tauri).context("failed to set current directory")?;
if let Some(before_dev) = config.build.before_dev_command.clone() {
let (script, script_cwd, wait) = match before_dev {
BeforeDevCommand::Script(s) if s.is_empty() => (None, None, false),
BeforeDevCommand::Script(s) => (Some(s), None, false),
BeforeDevCommand::ScriptWithOptions { script, cwd, wait } => {
(Some(script), cwd.map(Into::into), wait)
}
};
let cwd = script_cwd.unwrap_or_else(|| dirs.frontend.to_owned());
if let Some(before_dev) = script {
log::info!(action = "Running"; "BeforeDevCommand (`{}`)", before_dev);
let mut env = command_env(true);
env.extend(interface.env());
#[cfg(windows)]
let mut command = {
let mut command = Command::new("cmd");
command
.arg("/S")
.arg("/C")
.arg(&before_dev)
.current_dir(cwd)
.envs(env);
command
};
#[cfg(not(windows))]
let mut command = {
let mut command = Command::new("sh");
command
.arg("-c")
.arg(&before_dev)
.current_dir(cwd)
.envs(env);
command
};
if wait {
let status = command.piped().map_err(|error| Error::CommandFailed {
command: format!(
"`{before_dev}` with `{}`",
if cfg!(windows) { "cmd /S /C" } else { "sh -c" }
),
error,
})?;
if !status.success() {
crate::error::bail!(
"beforeDevCommand `{}` failed with exit code {}",
before_dev,
status.code().unwrap_or_default()
);
}
} else {
command.stdin(Stdio::piped());
command.stdout(os_pipe::dup_stdout().unwrap());
command.stderr(os_pipe::dup_stderr().unwrap());
let child = SharedChild::spawn(&mut command)
.unwrap_or_else(|_| panic!("failed to run `{before_dev}`"));
let child = BEFORE_DEV.get_or_init(move || child);
std::thread::spawn(move || {
let status = child
.wait()
.expect("failed to wait on \"beforeDevCommand\"");
if !(status.success() || KILL_BEFORE_DEV_FLAG.load(Ordering::SeqCst)) {
log::error!("The \"beforeDevCommand\" terminated with a non-zero status code.");
exit(status.code().unwrap_or(1));
}
});
let _ = ctrlc::set_handler(move || {
kill_before_dev_process();
exit(130);
});
}
}
}
if options.runner.is_none() {
options.runner = config.build.runner.clone();
}
let mut cargo_features = config.build.features.clone().unwrap_or_default();
cargo_features.extend(options.features.clone());
let mut dev_url = config.build.dev_url.clone();
let frontend_dist = config.build.frontend_dist.clone();
if !options.no_dev_server && dev_url.is_none() {
if let Some(FrontendDist::Directory(path)) = &frontend_dist {
if path.exists() {
let path = path
.canonicalize()
.fs_context("failed to canonicalize path", path.to_path_buf())?;
let ip = options.host.unwrap_or_else(|| Ipv4Addr::LOCALHOST.into());
let server_url = builtin_dev_server::start(path, ip, options.port)
.context("failed to start builtin dev server")?;
let server_url = format!("http://{server_url}");
dev_url = Some(server_url.parse().unwrap());
options.config.push(crate::ConfigValue(serde_json::json!({
"build": {
"devUrl": server_url
}
})));
reload_config(
config,
&options.config.iter().map(|c| &c.0).collect::<Vec<_>>(),
dirs.tauri,
)?;
}
}
}
if !options.no_dev_server_wait {
if let Some(url) = dev_url {
let host = url.host().expect("No host name in the URL");
let port = url
.port_or_known_default()
.expect("No port number in the URL");
let addrs;
let addr;
let addrs = match host {
url::Host::Domain(domain) => {
use std::net::ToSocketAddrs;
addrs = (domain, port).to_socket_addrs().unwrap();
addrs.as_slice()
}
url::Host::Ipv4(ip) => {
addr = (ip, port).into();
std::slice::from_ref(&addr)
}
url::Host::Ipv6(ip) => {
addr = (ip, port).into();
std::slice::from_ref(&addr)
}
};
let mut i = 0;
let sleep_interval = std::time::Duration::from_secs(2);
let timeout_duration = std::time::Duration::from_secs(1);
let max_attempts = 90;
'waiting: loop {
for addr in addrs.iter() {
if std::net::TcpStream::connect_timeout(addr, timeout_duration).is_ok() {
break 'waiting;
}
}
if i % 3 == 1 {
log::warn!("Waiting for your frontend dev server to start on {url}...",);
}
i += 1;
if i == max_attempts {
log::error!(
"Could not connect to `{url}` after {}s. Please make sure that is the URL to your dev server.",
i * sleep_interval.as_secs()
);
exit(1);
}
std::thread::sleep(sleep_interval);
}
}
}
if options.additional_watch_folders.is_empty() {
options
.additional_watch_folders
.extend(config.build.additional_watch_folders.clone());
}
Ok(())
}
pub fn on_app_exit(code: Option<i32>, reason: ExitReason, exit_on_panic: bool, no_watch: bool) {
if no_watch
|| (!matches!(reason, ExitReason::TriggeredKill)
&& (exit_on_panic || matches!(reason, ExitReason::NormalExit)))
{
kill_before_dev_process();
exit(code.unwrap_or(0));
}
}
pub fn kill_before_dev_process() {
if let Some(child) = BEFORE_DEV.get() {
if KILL_BEFORE_DEV_FLAG.load(Ordering::SeqCst) {
return;
}
KILL_BEFORE_DEV_FLAG.store(true, Ordering::SeqCst);
#[cfg(windows)]
{
let powershell_path = std::env::var("SYSTEMROOT").map_or_else(
|_| "powershell.exe".to_string(),
|p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
);
let _ = Command::new(powershell_path)
.arg("-NoProfile")
.arg("-Command")
.arg(format!("function Kill-Tree {{ Param([int]$ppid); Get-CimInstance Win32_Process | Where-Object {{ $_.ParentProcessId -eq $ppid }} | ForEach-Object {{ Kill-Tree $_.ProcessId }}; Stop-Process -Id $ppid -ErrorAction SilentlyContinue }}; Kill-Tree {}", child.id()))
.status();
}
#[cfg(unix)]
{
use std::io::Write;
let mut kill_children_script_path = std::env::temp_dir();
kill_children_script_path.push("tauri-stop-dev-processes.sh");
if !kill_children_script_path.exists() {
if let Ok(mut file) = std::fs::File::create(&kill_children_script_path) {
use std::os::unix::fs::PermissionsExt;
let _ = file.write_all(KILL_CHILDREN_SCRIPT);
let mut permissions = file.metadata().unwrap().permissions();
permissions.set_mode(0o770);
let _ = file.set_permissions(permissions);
}
}
let _ = Command::new(&kill_children_script_path)
.arg(child.id().to_string())
.output();
}
let _ = child.kill();
}
}

View File

@@ -0,0 +1,30 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
// taken from https://github.com/thedodd/trunk/blob/5c799dc35f1f1d8f8d3d30c8723cbb761a9b6a08/src/autoreload.js
;(function () {
const reload_url = '{{reload_url}}'
const url = reload_url ? reload_url : window.location.href
const poll_interval = 5000
const reload_upon_connect = () => {
window.setTimeout(() => {
// when we successfully reconnect, we'll force a
// reload (since we presumably lost connection to
// tauri-cli due to it being killed)
const ws = new WebSocket(url)
ws.onopen = () => window.location.reload()
ws.onclose = reload_upon_connect
}, poll_interval)
}
const ws = new WebSocket(url)
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data)
if (msg.reload) {
window.location.reload()
}
}
ws.onclose = reload_upon_connect
})()

View File

@@ -0,0 +1,169 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use axum::{
extract::{State, WebSocketUpgrade, ws},
http::{StatusCode, Uri, header},
response::{IntoResponse, Response},
};
use std::{
net::{IpAddr, SocketAddr},
path::{Path, PathBuf},
thread,
time::Duration,
};
use tauri_utils::mime_type::MimeType;
use tokio::sync::broadcast::{Sender, channel};
use crate::error::ErrorExt;
const RELOAD_SCRIPT: &str = include_str!("./auto-reload.js");
#[derive(Clone)]
struct ServerState {
dir: PathBuf,
address: SocketAddr,
tx: Sender<()>,
}
pub fn start<P: AsRef<Path>>(dir: P, ip: IpAddr, port: Option<u16>) -> crate::Result<SocketAddr> {
let dir = dir.as_ref();
let dir =
dunce::canonicalize(dir).fs_context("failed to canonicalize path", dir.to_path_buf())?;
// bind port and tcp listener
let auto_port = port.is_none();
let mut port = port.unwrap_or(1430);
let (tcp_listener, address) = loop {
let address = SocketAddr::new(ip, port);
if let Ok(tcp) = std::net::TcpListener::bind(address) {
tcp.set_nonblocking(true).unwrap();
break (tcp, address);
}
if !auto_port {
crate::error::bail!("Couldn't bind to {port} on {ip}");
}
port += 1;
};
let (tx, _) = channel(1);
// watch dir for changes
let tx_c = tx.clone();
watch(dir.clone(), move || {
let _ = tx_c.send(());
});
let state = ServerState { dir, tx, address };
// start router thread
std::thread::spawn(move || {
tokio::runtime::Builder::new_current_thread()
.enable_io()
.build()
.expect("failed to start tokio runtime for builtin dev server")
.block_on(async move {
let router = axum::Router::new()
.fallback(handler)
.route("/__tauri_cli", axum::routing::get(ws_handler))
.with_state(state);
axum::serve(tokio::net::TcpListener::from_std(tcp_listener)?, router).await
})
.expect("builtin server errored");
});
Ok(address)
}
async fn handler(uri: Uri, state: State<ServerState>) -> impl IntoResponse {
// Frontend files should not contain query parameters. This seems to be how Vite handles it.
let uri = uri.path();
let uri = if uri == "/" {
uri
} else {
uri.strip_prefix('/').unwrap_or(uri)
};
let bytes = fs_read_scoped(state.dir.join(uri), &state.dir)
.or_else(|_| fs_read_scoped(state.dir.join(format!("{uri}.html")), &state.dir))
.or_else(|_| fs_read_scoped(state.dir.join(format!("{uri}/index.html")), &state.dir))
.or_else(|_| std::fs::read(state.dir.join("index.html")));
match bytes {
Ok(mut bytes) => {
let mime_type = MimeType::parse_with_fallback(&bytes, uri, MimeType::OctetStream);
if mime_type == MimeType::Html.to_string() {
bytes = inject_address(bytes, &state.address);
}
(StatusCode::OK, [(header::CONTENT_TYPE, mime_type)], bytes)
}
Err(_) => (
StatusCode::NOT_FOUND,
[(header::CONTENT_TYPE, "text/plain".into())],
vec![],
),
}
}
async fn ws_handler(ws: WebSocketUpgrade, state: State<ServerState>) -> Response {
ws.on_upgrade(move |mut ws| async move {
let mut rx = state.tx.subscribe();
while tokio::select! {
_ = ws.recv() => return,
fs_reload_event = rx.recv() => fs_reload_event.is_ok(),
} {
let msg = ws::Message::Text(r#"{"reload": true}"#.into());
if ws.send(msg).await.is_err() {
break;
}
}
})
}
fn inject_address(html_bytes: Vec<u8>, address: &SocketAddr) -> Vec<u8> {
let document = tauri_utils::html2::parse_doc(String::from_utf8_lossy(&html_bytes).into_owned());
tauri_utils::html2::append_script_to_head(
&document,
&RELOAD_SCRIPT.replace("{{reload_url}}", &format!("ws://{address}/__tauri_cli")),
);
tauri_utils::html2::serialize_doc(&document)
}
fn fs_read_scoped(path: PathBuf, scope: &Path) -> crate::Result<Vec<u8>> {
let path = dunce::canonicalize(&path).fs_context("failed to canonicalize path", path)?;
if path.starts_with(scope) {
std::fs::read(&path).fs_context("failed to read file", &path)
} else {
crate::error::bail!("forbidden path")
}
}
fn watch<F: Fn() + Send + 'static>(dir: PathBuf, handler: F) {
thread::spawn(move || {
let (tx, rx) = std::sync::mpsc::channel();
let mut watcher = notify_debouncer_full::new_debouncer(Duration::from_secs(1), None, tx)
.expect("failed to start builtin server fs watcher");
watcher
.watch(&dir, notify::RecursiveMode::Recursive)
.expect("builtin server failed to watch dir");
loop {
if let Ok(Ok(event)) = rx.recv() {
if let Some(event) = event.first() {
if !event.kind.is_access() {
handler();
}
}
}
}
});
}

View File

@@ -0,0 +1,105 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::{fmt::Display, path::PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("{0}: {1}")]
Context(String, Box<dyn std::error::Error + Send + Sync + 'static>),
#[error("{0}")]
GenericError(String),
#[error("failed to bundle project: {0}")]
Bundler(#[from] tauri_bundler::Error),
#[error("{context} {path}: {error}")]
Fs {
context: &'static str,
path: PathBuf,
error: std::io::Error,
},
#[error("failed to run command {command}: {error}")]
CommandFailed {
command: String,
error: std::io::Error,
},
#[cfg(target_os = "macos")]
#[error(transparent)]
MacosSign(#[from] Box<tauri_macos_sign::Error>),
}
/// Convenient type alias of Result type.
pub type Result<T> = std::result::Result<T, Error>;
pub trait Context<T> {
// Required methods
fn context<C>(self, context: C) -> Result<T>
where
C: Display + Send + Sync + 'static;
fn with_context<C, F>(self, f: F) -> Result<T>
where
C: Display + Send + Sync + 'static,
F: FnOnce() -> C;
}
impl<T, E: std::error::Error + Send + Sync + 'static> Context<T> for std::result::Result<T, E> {
fn context<C>(self, context: C) -> Result<T>
where
C: Display + Send + Sync + 'static,
{
self.map_err(|e| Error::Context(context.to_string(), Box::new(e)))
}
fn with_context<C, F>(self, f: F) -> Result<T>
where
C: Display + Send + Sync + 'static,
F: FnOnce() -> C,
{
self.map_err(|e| Error::Context(f().to_string(), Box::new(e)))
}
}
impl<T> Context<T> for Option<T> {
fn context<C>(self, context: C) -> Result<T>
where
C: Display + Send + Sync + 'static,
{
self.ok_or_else(|| Error::GenericError(context.to_string()))
}
fn with_context<C, F>(self, f: F) -> Result<T>
where
C: Display + Send + Sync + 'static,
F: FnOnce() -> C,
{
self.ok_or_else(|| Error::GenericError(f().to_string()))
}
}
pub trait ErrorExt<T> {
fn fs_context(self, context: &'static str, path: impl Into<PathBuf>) -> Result<T>;
}
impl<T> ErrorExt<T> for std::result::Result<T, std::io::Error> {
fn fs_context(self, context: &'static str, path: impl Into<PathBuf>) -> Result<T> {
self.map_err(|error| Error::Fs {
context,
path: path.into(),
error,
})
}
}
macro_rules! bail {
($msg:literal $(,)?) => {
return Err(crate::Error::GenericError($msg.into()))
};
($err:expr $(,)?) => {
return Err(crate::Error::GenericError($err))
};
($fmt:expr, $($arg:tt)*) => {
return Err(crate::Error::GenericError(format!($fmt, $($arg)*)))
};
}
pub(crate) use bail;

View File

@@ -0,0 +1,167 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::{
cmp::Ordering,
env::current_dir,
ffi::OsStr,
path::{Path, PathBuf},
sync::OnceLock,
};
use ignore::WalkBuilder;
use tauri_utils::{
config::parse::{ConfigFormat, folder_has_configuration_file, is_configuration_file},
platform::Target,
};
const TAURI_GITIGNORE: &[u8] = include_bytes!("../../tauri.gitignore");
// path to the Tauri app (Rust crate) directory, usually `<project>/src-tauri/`
const ENV_TAURI_APP_PATH: &str = "TAURI_APP_PATH";
// path to the frontend app directory, usually `<project>/`
const ENV_TAURI_FRONTEND_PATH: &str = "TAURI_FRONTEND_PATH";
pub struct Dirs {
pub tauri: &'static Path,
pub frontend: &'static Path,
}
static FRONTEND_DIR: OnceLock<PathBuf> = OnceLock::new();
static TAURI_DIR: OnceLock<PathBuf> = OnceLock::new();
pub fn walk_builder(path: &Path) -> WalkBuilder {
let mut default_gitignore = std::env::temp_dir();
default_gitignore.push(".gitignore");
if !default_gitignore.exists() {
if let Ok(mut file) = std::fs::File::create(default_gitignore.clone()) {
use std::io::Write;
let _ = file.write_all(TAURI_GITIGNORE);
}
}
let mut builder = WalkBuilder::new(path);
builder.add_custom_ignore_filename(".taurignore");
builder.git_global(false);
builder.parents(false);
let _ = builder.add_ignore(default_gitignore);
builder
}
fn lookup<F: Fn(&PathBuf) -> bool>(dir: &Path, checker: F) -> Option<PathBuf> {
let mut builder = walk_builder(dir);
builder
.require_git(false)
.ignore(false)
.max_depth(Some(
std::env::var("TAURI_CLI_CONFIG_DEPTH")
.map(|d| {
d.parse()
.expect("`TAURI_CLI_CONFIG_DEPTH` environment variable must be a positive integer")
})
.unwrap_or(3),
))
.sort_by_file_path(|a, _| {
if a.extension().is_some() {
Ordering::Less
} else {
Ordering::Greater
}
});
for entry in builder.build().flatten() {
let path = dir.join(entry.path());
if checker(&path) {
return Some(path);
}
}
None
}
fn env_tauri_app_path() -> Option<PathBuf> {
let p = PathBuf::from(std::env::var_os(ENV_TAURI_APP_PATH)?);
dunce::canonicalize(p).ok()
}
fn env_tauri_frontend_path() -> Option<PathBuf> {
let p = PathBuf::from(std::env::var_os(ENV_TAURI_FRONTEND_PATH)?);
dunce::canonicalize(p).ok()
}
pub fn resolve_tauri_dir() -> Option<PathBuf> {
let src_dir = env_tauri_app_path().or_else(|| current_dir().ok())?;
for standard_tauri_path in [src_dir.clone(), src_dir.join("src-tauri")] {
if standard_tauri_path
.join(ConfigFormat::Json.into_file_name())
.exists()
|| standard_tauri_path
.join(ConfigFormat::Json5.into_file_name())
.exists()
|| standard_tauri_path
.join(ConfigFormat::Toml.into_file_name())
.exists()
{
log::debug!(
"Found Tauri project inside {} on early lookup",
standard_tauri_path.display()
);
return Some(standard_tauri_path);
}
}
log::debug!("resolving Tauri directory from {}", src_dir.display());
lookup(&src_dir, |path| {
folder_has_configuration_file(Target::Linux, path) || is_configuration_file(Target::Linux, path)
})
.map(|p| {
if p.is_dir() {
log::debug!("Found Tauri project directory {}", p.display());
p
} else {
log::debug!("Found Tauri project configuration file {}", p.display());
p.parent().unwrap().to_path_buf()
}
})
}
pub fn resolve_dirs() -> Dirs {
let tauri = TAURI_DIR.get_or_init(|| resolve_tauri_dir().unwrap_or_else(|| {
let env_var_name = env_tauri_app_path().is_some().then(|| format!("`{ENV_TAURI_APP_PATH}`"));
panic!("Couldn't recognize the {} folder as a Tauri project. It must contain a `{}`, `{}` or `{}` file in any subfolder.",
env_var_name.as_deref().unwrap_or("current"),
ConfigFormat::Json.into_file_name(),
ConfigFormat::Json5.into_file_name(),
ConfigFormat::Toml.into_file_name()
)
}));
let frontend = FRONTEND_DIR.get_or_init(|| {
resolve_frontend_dir().unwrap_or_else(|| tauri.parent().unwrap().to_path_buf())
});
Dirs { tauri, frontend }
}
pub fn resolve_frontend_dir() -> Option<PathBuf> {
let frontend_dir =
env_tauri_frontend_path().unwrap_or_else(|| current_dir().expect("failed to read cwd"));
if frontend_dir.join("package.json").exists() {
return Some(frontend_dir);
}
log::debug!(
"resolving frontend directory from {}",
frontend_dir.display()
);
lookup(&frontend_dir, |path| {
if let Some(file_name) = path.file_name() {
file_name == OsStr::new("package.json")
} else {
false
}
})
.map(|p| p.parent().unwrap().to_path_buf())
}

View File

@@ -0,0 +1,99 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::process::Command;
use crate::Error;
#[derive(Debug, Default, Clone, Copy)]
pub struct CargoInstallOptions<'a> {
pub name: &'a str,
pub version: Option<&'a str>,
pub rev: Option<&'a str>,
pub tag: Option<&'a str>,
pub branch: Option<&'a str>,
pub cwd: Option<&'a std::path::Path>,
pub target: Option<&'a str>,
}
pub fn install_one(options: CargoInstallOptions) -> crate::Result<()> {
let mut cargo = Command::new("cargo");
cargo.arg("add");
if let Some(version) = options.version {
cargo.arg(format!("{}@{}", options.name, version));
} else {
cargo.arg(options.name);
if options.tag.is_some() || options.rev.is_some() || options.branch.is_some() {
cargo.args(["--git", "https://github.com/tauri-apps/plugins-workspace"]);
}
match (options.tag, options.rev, options.branch) {
(Some(tag), None, None) => {
cargo.args(["--tag", tag]);
}
(None, Some(rev), None) => {
cargo.args(["--rev", rev]);
}
(None, None, Some(branch)) => {
cargo.args(["--branch", branch]);
}
(None, None, None) => {}
_ => crate::error::bail!("Only one of --tag, --rev and --branch can be specified"),
};
}
if let Some(target) = options.target {
cargo.args(["--target", target]);
}
if let Some(cwd) = options.cwd {
cargo.current_dir(cwd);
}
log::info!("Installing Cargo dependency \"{}\"...", options.name);
let status = cargo.status().map_err(|error| Error::CommandFailed {
command: "cargo add".to_string(),
error,
})?;
if !status.success() {
crate::error::bail!("Failed to install Cargo dependency");
}
Ok(())
}
#[derive(Debug, Default, Clone, Copy)]
pub struct CargoUninstallOptions<'a> {
pub name: &'a str,
pub cwd: Option<&'a std::path::Path>,
pub target: Option<&'a str>,
}
pub fn uninstall_one(options: CargoUninstallOptions) -> crate::Result<()> {
let mut cargo = Command::new("cargo");
cargo.arg("remove");
cargo.arg(options.name);
if let Some(target) = options.target {
cargo.args(["--target", target]);
}
if let Some(cwd) = options.cwd {
cargo.current_dir(cwd);
}
log::info!("Uninstalling Cargo dependency \"{}\"...", options.name);
let status = cargo.status().map_err(|error| Error::CommandFailed {
command: "cargo remove".to_string(),
error,
})?;
if !status.success() {
crate::error::bail!("Failed to remove Cargo dependency");
}
Ok(())
}

View File

@@ -0,0 +1,209 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use serde::Deserialize;
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
use crate::interface::rust::get_workspace_dir;
#[derive(Clone, Deserialize)]
pub struct CargoLockPackage {
pub name: String,
pub version: String,
pub source: Option<String>,
}
#[derive(Deserialize)]
pub struct CargoLock {
pub package: Vec<CargoLockPackage>,
}
#[derive(Clone, Deserialize)]
pub struct CargoManifestDependencyPackage {
pub version: Option<String>,
pub git: Option<String>,
pub branch: Option<String>,
pub rev: Option<String>,
pub path: Option<PathBuf>,
}
#[derive(Clone, Deserialize)]
#[serde(untagged)]
pub enum CargoManifestDependency {
Version(String),
Package(CargoManifestDependencyPackage),
}
#[derive(Deserialize)]
pub struct CargoManifestPackage {
pub version: String,
}
#[derive(Deserialize)]
pub struct CargoManifest {
pub package: CargoManifestPackage,
pub dependencies: HashMap<String, CargoManifestDependency>,
}
pub fn cargo_manifest_and_lock(tauri_dir: &Path) -> (Option<CargoManifest>, Option<CargoLock>) {
let manifest: Option<CargoManifest> = fs::read_to_string(tauri_dir.join("Cargo.toml"))
.ok()
.and_then(|manifest_contents| toml::from_str(&manifest_contents).ok());
let lock: Option<CargoLock> = get_workspace_dir(tauri_dir)
.ok()
.and_then(|p| fs::read_to_string(p.join("Cargo.lock")).ok())
.and_then(|s| toml::from_str(&s).ok());
(manifest, lock)
}
#[derive(Default)]
pub struct CrateVersion {
pub version: Option<String>,
pub git: Option<String>,
pub git_branch: Option<String>,
pub git_rev: Option<String>,
pub path: Option<PathBuf>,
pub lock_version: Option<String>,
}
impl CrateVersion {
pub fn has_version(&self) -> bool {
self.version.is_some() || self.git.is_some() || self.path.is_some()
}
}
impl std::fmt::Display for CrateVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(g) = &self.git {
if let Some(version) = &self.version {
write!(f, "{g} ({version})")?;
} else {
write!(f, "git:{g}")?;
if let Some(branch) = &self.git_branch {
write!(f, "&branch={branch}")?;
} else if let Some(rev) = &self.git_rev {
write!(f, "#rev={rev}")?;
}
}
} else if let Some(p) = &self.path {
write!(f, "path:{}", p.display())?;
if let Some(version) = &self.version {
write!(f, " ({version})")?;
}
} else if let Some(version) = &self.version {
write!(f, "{version}")?;
} else {
return write!(f, "No version detected");
}
if let Some(lock_version) = &self.lock_version {
write!(f, " ({lock_version})")?;
}
Ok(())
}
}
// Reference: https://github.com/rust-lang/crates.io/blob/98c83c8231cbcd15d6b8f06d80a00ad462f71585/src/views.rs#L274
#[derive(serde::Deserialize)]
struct CrateMetadata {
/// The "default" version of this crate.
///
/// This version will be displayed by default on the crate's page.
pub default_version: Option<String>,
}
// Reference: https://github.com/rust-lang/crates.io/blob/98c83c8231cbcd15d6b8f06d80a00ad462f71585/src/controllers/krate/metadata.rs#L44
#[derive(serde::Deserialize)]
struct CrateIoGetResponse {
/// The crate metadata.
#[serde(rename = "crate")]
krate: CrateMetadata,
}
pub fn crate_latest_version(name: &str) -> Option<String> {
// Reference: https://github.com/rust-lang/crates.io/blob/98c83c8231cbcd15d6b8f06d80a00ad462f71585/src/controllers/krate/metadata.rs#L88
let url = format!("https://crates.io/api/v1/crates/{name}?include");
let mut response = super::http::get(&url).ok()?;
let metadata: CrateIoGetResponse =
serde_json::from_reader(response.body_mut().as_reader()).unwrap();
metadata.krate.default_version
}
pub fn crate_version(
tauri_dir: &Path,
manifest: Option<&CargoManifest>,
lock: Option<&CargoLock>,
name: &str,
) -> CrateVersion {
let mut version = CrateVersion::default();
let crate_lock_packages: Vec<CargoLockPackage> = lock
.as_ref()
.map(|lock| {
lock
.package
.iter()
.filter(|p| p.name == name)
.cloned()
.collect()
})
.unwrap_or_default();
if crate_lock_packages.len() == 1 {
let crate_lock_package = crate_lock_packages.first().unwrap();
if let Some(s) = crate_lock_package
.source
.as_ref()
.filter(|s| s.starts_with("git"))
{
version.git = Some(s.clone());
}
version.version = Some(crate_lock_package.version.clone());
} else {
if let Some(dep) = manifest.and_then(|m| m.dependencies.get(name).cloned()) {
match dep {
CargoManifestDependency::Version(v) => version.version = Some(v),
CargoManifestDependency::Package(p) => {
if let Some(v) = p.version {
version.version = Some(v);
} else if let Some(p) = p.path {
let manifest_path = tauri_dir.join(&p).join("Cargo.toml");
let v = fs::read_to_string(manifest_path)
.ok()
.and_then(|m| toml::from_str::<CargoManifest>(&m).ok())
.map(|m| m.package.version);
version.version = v;
version.path = Some(p);
} else if let Some(g) = p.git {
version.git = Some(g);
version.git_branch = p.branch;
version.git_rev = p.rev;
}
}
}
}
if lock.is_some() && crate_lock_packages.is_empty() {
let lock_version = crate_lock_packages
.iter()
.map(|p| p.version.clone())
.collect::<Vec<String>>()
.join(", ");
if !lock_version.is_empty() {
version.lock_version = Some(lock_version);
}
}
}
version
}

View File

@@ -0,0 +1,362 @@
// Copyright 2019-2025 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use itertools::Itertools;
use json_patch::merge;
use serde_json::Value as JsonValue;
use tauri_utils::acl::REMOVE_UNUSED_COMMANDS_ENV_VAR;
pub use tauri_utils::{config::*, platform::Target};
use std::{
collections::HashMap,
env::{current_dir, set_current_dir, set_var},
ffi::{OsStr, OsString},
path::Path,
process::exit,
sync::LazyLock,
};
use crate::error::Context;
pub const MERGE_CONFIG_EXTENSION_NAME: &str = "--config";
pub struct ConfigMetadata {
/// The current target.
target: Target,
original_identifier: Option<String>,
/// The actual configuration, merged with any extension.
inner: Config,
/// The config extensions (platform-specific config files or the config CLI argument).
/// Maps the extension name to its value.
extensions: HashMap<OsString, JsonValue>,
}
impl std::ops::Deref for ConfigMetadata {
type Target = Config;
#[inline(always)]
fn deref(&self) -> &Config {
&self.inner
}
}
impl ConfigMetadata {
/// The original bundle identifier from the config file.
/// This does not take any extensions into account.
pub fn original_identifier(&self) -> Option<&str> {
self.original_identifier.as_deref()
}
/// Checks which config is overwriting the bundle identifier.
pub fn find_bundle_identifier_overwriter(&self) -> Option<OsString> {
for (ext, config) in &self.extensions {
if let Some(identifier) = config
.as_object()
.and_then(|bundle_config| bundle_config.get("identifier")?.as_str())
{
if identifier == self.inner.identifier {
return Some(ext.clone());
}
}
}
None
}
}
pub fn wix_settings(config: WixConfig) -> tauri_bundler::WixSettings {
tauri_bundler::WixSettings {
version: config.version,
upgrade_code: config.upgrade_code,
fips_compliant: std::env::var_os("TAURI_BUNDLER_WIX_FIPS_COMPLIANT")
.map(|v| v == "true")
.unwrap_or(config.fips_compliant),
language: tauri_bundler::WixLanguage(match config.language {
WixLanguage::One(lang) => vec![(lang, Default::default())],
WixLanguage::List(languages) => languages
.into_iter()
.map(|lang| (lang, Default::default()))
.collect(),
WixLanguage::Localized(languages) => languages
.into_iter()
.map(|(lang, config)| {
(
lang,
tauri_bundler::WixLanguageConfig {
locale_path: config.locale_path.map(Into::into),
},
)
})
.collect(),
}),
template: config.template,
fragment_paths: config.fragment_paths,
component_group_refs: config.component_group_refs,
component_refs: config.component_refs,
feature_group_refs: config.feature_group_refs,
feature_refs: config.feature_refs,
merge_refs: config.merge_refs,
enable_elevated_update_task: config.enable_elevated_update_task,
banner_path: config.banner_path,
dialog_image_path: config.dialog_image_path,
}
}
pub fn nsis_settings(config: NsisConfig) -> tauri_bundler::NsisSettings {
tauri_bundler::NsisSettings {
template: config.template,
header_image: config.header_image,
sidebar_image: config.sidebar_image,
installer_icon: config.installer_icon,
uninstaller_icon: config.uninstaller_icon,
uninstaller_header_image: config.uninstaller_header_image,
install_mode: config.install_mode,
languages: config.languages,
custom_language_files: config.custom_language_files,
display_language_selector: config.display_language_selector,
compression: config.compression,
start_menu_folder: config.start_menu_folder,
installer_hooks: config.installer_hooks,
#[allow(deprecated)]
minimum_webview2_version: config.minimum_webview2_version,
}
}
pub fn custom_sign_settings(
config: CustomSignCommandConfig,
) -> tauri_bundler::CustomSignCommandSettings {
match config {
CustomSignCommandConfig::Command(command) => {
let mut tokens = command.split(' ');
tauri_bundler::CustomSignCommandSettings {
cmd: tokens.next().unwrap().to_string(), // split always has at least one element
args: tokens.map(String::from).collect(),
}
}
CustomSignCommandConfig::CommandWithOptions { cmd, args } => {
tauri_bundler::CustomSignCommandSettings { cmd, args }
}
}
}
static CONFIG_SCHEMA_VALIDATOR: LazyLock<jsonschema::Validator> = LazyLock::new(|| {
let schema: JsonValue = serde_json::from_str(include_str!("../../config.schema.json"))
.expect("Failed to parse config schema bundled in the tauri-cli");
jsonschema::validator_for(&schema).expect("Config schema bundled in the tauri-cli is invalid")
});
fn load_config(
merge_configs: &[&serde_json::Value],
reload: bool,
target: Target,
tauri_dir: &Path,
) -> crate::Result<ConfigMetadata> {
let (mut config, config_path) =
tauri_utils::config::parse::parse_value(target, tauri_dir.join("tauri.conf.json"))
.context("failed to parse config")?;
let config_file_name = config_path.file_name().unwrap();
let mut extensions = HashMap::new();
let original_identifier = config
.as_object()
.and_then(|config| config.get("identifier")?.as_str())
.map(ToString::to_string);
if let Some((platform_config, config_path)) =
tauri_utils::config::parse::read_platform(target, tauri_dir)
.context("failed to parse platform config")?
{
merge(&mut config, &platform_config);
extensions.insert(config_path.file_name().unwrap().into(), platform_config);
}
if !merge_configs.is_empty() {
let mut merge_config = serde_json::Value::Object(Default::default());
for conf in merge_configs {
merge_patches(&mut merge_config, conf);
}
let merge_config_str = serde_json::to_string(&merge_config).unwrap();
unsafe { set_var("TAURI_CONFIG", merge_config_str) };
merge(&mut config, &merge_config);
extensions.insert(MERGE_CONFIG_EXTENSION_NAME.into(), merge_config);
}
if config_path.extension() == Some(OsStr::new("json"))
|| config_path.extension() == Some(OsStr::new("json5"))
{
let mut errors = CONFIG_SCHEMA_VALIDATOR.iter_errors(&config).peekable();
if errors.peek().is_some() {
for error in errors {
let path = error.instance_path().into_iter().join(" > ");
if path.is_empty() {
log::error!("`{config_file_name:?}` error: {error}");
} else {
log::error!("`{config_file_name:?}` error on `{path}`: {error}");
}
}
if !reload {
exit(1);
}
}
}
// the `Config` deserializer for `package > version` can resolve the version from a path relative to the config path
// so we actually need to change the current working directory here
let current_dir = current_dir().context("failed to resolve current directory")?;
set_current_dir(config_path.parent().unwrap()).context("failed to set current directory")?;
let config: Config = serde_json::from_value(config).context("failed to parse config")?;
// revert to previous working directory
set_current_dir(current_dir).context("failed to set current directory")?;
for (plugin, conf) in &config.plugins.0 {
unsafe {
set_var(
format!(
"TAURI_{}_PLUGIN_CONFIG",
plugin.to_uppercase().replace('-', "_")
),
serde_json::to_string(&conf).context("failed to serialize config")?,
)
};
}
if config.build.remove_unused_commands {
unsafe { std::env::set_var(REMOVE_UNUSED_COMMANDS_ENV_VAR, tauri_dir) };
}
Ok(ConfigMetadata {
target,
original_identifier,
inner: config,
extensions,
})
}
pub fn get_config(
target: Target,
merge_configs: &[&serde_json::Value],
tauri_dir: &Path,
) -> crate::Result<ConfigMetadata> {
load_config(merge_configs, false, target, tauri_dir)
}
pub fn reload_config(
config: &mut ConfigMetadata,
merge_configs: &[&serde_json::Value],
tauri_dir: &Path,
) -> crate::Result<()> {
let target = config.target;
*config = load_config(merge_configs, true, target, tauri_dir)?;
Ok(())
}
/// merges the loaded config with the given value
pub fn merge_config_with(
config: &mut ConfigMetadata,
merge_configs: &[&serde_json::Value],
) -> crate::Result<()> {
if merge_configs.is_empty() {
return Ok(());
}
let mut merge_config = serde_json::Value::Object(Default::default());
for conf in merge_configs {
merge_patches(&mut merge_config, conf);
}
let merge_config_str = serde_json::to_string(&merge_config).unwrap();
unsafe { set_var("TAURI_CONFIG", merge_config_str) };
let mut value =
serde_json::to_value(config.inner.clone()).context("failed to serialize config")?;
merge(&mut value, &merge_config);
config.inner = serde_json::from_value(value).context("failed to parse config")?;
Ok(())
}
/// Same as [`json_patch::merge`] but doesn't delete the key when the patch's value is `null`
fn merge_patches(doc: &mut serde_json::Value, patch: &serde_json::Value) {
use serde_json::{Map, Value};
if !patch.is_object() {
*doc = patch.clone();
return;
}
if !doc.is_object() {
*doc = Value::Object(Map::new());
}
let map = doc.as_object_mut().unwrap();
for (key, value) in patch.as_object().unwrap() {
merge_patches(map.entry(key.as_str()).or_insert(Value::Null), value);
}
}
#[cfg(test)]
mod tests {
#[test]
fn merge_patches() {
let mut json = serde_json::Value::Object(Default::default());
super::merge_patches(
&mut json,
&serde_json::json!({
"app": {
"withGlobalTauri": true,
"windows": []
},
"plugins": {
"test": "tauri"
},
"build": {
"devUrl": "http://localhost:8080"
}
}),
);
super::merge_patches(
&mut json,
&serde_json::json!({
"app": { "withGlobalTauri": null }
}),
);
super::merge_patches(
&mut json,
&serde_json::json!({
"app": { "windows": null }
}),
);
super::merge_patches(
&mut json,
&serde_json::json!({
"plugins": { "updater": {
"endpoints": ["https://tauri.app"]
} }
}),
);
assert_eq!(
json,
serde_json::json!({
"app": {
"withGlobalTauri": null,
"windows": null
},
"plugins": {
"test": "tauri",
"updater": {
"endpoints": ["https://tauri.app"]
}
},
"build": {
"devUrl": "http://localhost:8080"
}
})
)
}
}

View File

@@ -0,0 +1,374 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
// taken from https://github.com/rust-lang/cargo/blob/b0c9586f4cbf426914df47c65de38ea323772c74/src/cargo/util/flock.rs
#![allow(dead_code)]
use std::fs::{File, OpenOptions, create_dir_all};
use std::io;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use crate::{Error, Result, error::ErrorExt};
use sys::*;
#[derive(Debug)]
pub struct FileLock {
f: Option<File>,
path: PathBuf,
state: State,
}
#[derive(PartialEq, Debug)]
enum State {
Unlocked,
Shared,
Exclusive,
}
impl FileLock {
/// Returns the underlying file handle of this lock.
pub fn file(&self) -> &File {
self.f.as_ref().unwrap()
}
/// Returns the underlying path that this lock points to.
///
/// Note that special care must be taken to ensure that the path is not
/// referenced outside the lifetime of this lock.
pub fn path(&self) -> &Path {
assert_ne!(self.state, State::Unlocked);
&self.path
}
/// Returns the parent path containing this file
pub fn parent(&self) -> &Path {
assert_ne!(self.state, State::Unlocked);
self.path.parent().unwrap()
}
}
impl Read for FileLock {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.file().read(buf)
}
}
impl Seek for FileLock {
fn seek(&mut self, to: SeekFrom) -> io::Result<u64> {
self.file().seek(to)
}
}
impl Write for FileLock {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.file().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.file().flush()
}
}
impl Drop for FileLock {
fn drop(&mut self) {
if self.state != State::Unlocked {
if let Some(f) = self.f.take() {
let _ = unlock(&f);
}
}
}
}
/// Opens exclusive access to a file, returning the locked version of a
/// file.
///
/// This function will create a file at `path` if it doesn't already exist
/// (including intermediate directories), and then it will acquire an
/// exclusive lock on `path`. If the process must block waiting for the
/// lock, the `msg` is logged.
///
/// The returned file can be accessed to look at the path and also has
/// read/write access to the underlying file.
pub fn open_rw<P>(path: P, msg: &str) -> Result<FileLock>
where
P: AsRef<Path>,
{
open(
path.as_ref(),
OpenOptions::new().read(true).write(true).create(true),
State::Exclusive,
msg,
)
}
/// Opens shared access to a file, returning the locked version of a file.
///
/// This function will fail if `path` doesn't already exist, but if it does
/// then it will acquire a shared lock on `path`. If the process must block
/// waiting for the lock, the `msg` is logged.
///
/// The returned file can be accessed to look at the path and also has read
/// access to the underlying file. Any writes to the file will return an
/// error.
pub fn open_ro<P>(path: P, msg: &str) -> Result<FileLock>
where
P: AsRef<Path>,
{
open(
path.as_ref(),
OpenOptions::new().read(true),
State::Shared,
msg,
)
}
fn open(path: &Path, opts: &OpenOptions, state: State, msg: &str) -> Result<FileLock> {
// If we want an exclusive lock then if we fail because of NotFound it's
// likely because an intermediate directory didn't exist, so try to
// create the directory and then continue.
let f = opts.open(path).or_else(|e| {
if e.kind() == io::ErrorKind::NotFound && state == State::Exclusive {
create_dir_all(path.parent().unwrap()).fs_context(
"failed to create directory",
path.parent().unwrap().to_path_buf(),
)?;
Ok(
opts
.open(path)
.fs_context("failed to open file", path.to_path_buf())?,
)
} else {
Err(Error::Fs {
context: "failed to open file",
path: path.to_path_buf(),
error: e,
})
}
})?;
match state {
State::Exclusive => {
acquire(msg, path, &|| try_lock_exclusive(&f), &|| {
lock_exclusive(&f)
})?;
}
State::Shared => {
acquire(msg, path, &|| try_lock_shared(&f), &|| lock_shared(&f))?;
}
State::Unlocked => {}
}
Ok(FileLock {
f: Some(f),
path: path.to_path_buf(),
state,
})
}
/// Acquires a lock on a file in a "nice" manner.
///
/// Almost all long-running blocking actions in Cargo have a status message
/// associated with them as we're not sure how long they'll take. Whenever a
/// conflicted file lock happens, this is the case (we're not sure when the lock
/// will be released).
///
/// This function will acquire the lock on a `path`, printing out a nice message
/// to the console if we have to wait for it. It will first attempt to use `try`
/// to acquire a lock on the crate, and in the case of contention it will emit a
/// status message based on `msg` to `config`'s shell, and then use `block` to
/// block waiting to acquire a lock.
///
/// Returns an error if the lock could not be acquired or if any error other
/// than a contention error happens.
fn acquire(
msg: &str,
path: &Path,
lock_try: &dyn Fn() -> io::Result<()>,
lock_block: &dyn Fn() -> io::Result<()>,
) -> Result<()> {
// File locking on Unix is currently implemented via `flock`, which is known
// to be broken on NFS. We could in theory just ignore errors that happen on
// NFS, but apparently the failure mode [1] for `flock` on NFS is **blocking
// forever**, even if the "non-blocking" flag is passed!
//
// As a result, we just skip all file locks entirely on NFS mounts. That
// should avoid calling any `flock` functions at all, and it wouldn't work
// there anyway.
//
// [1]: https://github.com/rust-lang/cargo/issues/2615
if is_on_nfs_mount(path) {
return Ok(());
}
match lock_try() {
Ok(()) => return Ok(()),
// In addition to ignoring NFS which is commonly not working we also
// just ignore locking on filesystems that look like they don't
// implement file locking.
Err(e) if error_unsupported(&e) => return Ok(()),
Err(e) => {
if !error_contended(&e) {
return Err(Error::Fs {
context: "failed to lock file",
path: path.to_path_buf(),
error: e,
});
}
}
}
let msg = format!("waiting for file lock on {msg}");
log::info!(action = "Blocking"; "{msg}");
lock_block().fs_context("failed to lock file", path.to_path_buf())?;
return Ok(());
#[cfg(all(target_os = "linux", not(target_env = "musl")))]
fn is_on_nfs_mount(path: &Path) -> bool {
use std::ffi::CString;
use std::mem;
use std::os::unix::prelude::*;
let path = match CString::new(path.as_os_str().as_bytes()) {
Ok(path) => path,
Err(_) => return false,
};
unsafe {
let mut buf: libc::statfs = mem::zeroed();
let r = libc::statfs(path.as_ptr(), &mut buf);
r == 0 && buf.f_type as u32 == libc::NFS_SUPER_MAGIC as u32
}
}
#[cfg(any(not(target_os = "linux"), target_env = "musl"))]
fn is_on_nfs_mount(_path: &Path) -> bool {
false
}
}
#[cfg(unix)]
mod sys {
use std::fs::File;
use std::io::{Error, Result};
use std::os::unix::io::AsRawFd;
pub(super) fn lock_shared(file: &File) -> Result<()> {
flock(file, libc::LOCK_SH)
}
pub(super) fn lock_exclusive(file: &File) -> Result<()> {
flock(file, libc::LOCK_EX)
}
pub(super) fn try_lock_shared(file: &File) -> Result<()> {
flock(file, libc::LOCK_SH | libc::LOCK_NB)
}
pub(super) fn try_lock_exclusive(file: &File) -> Result<()> {
flock(file, libc::LOCK_EX | libc::LOCK_NB)
}
pub(super) fn unlock(file: &File) -> Result<()> {
flock(file, libc::LOCK_UN)
}
pub(super) fn error_contended(err: &Error) -> bool {
err.raw_os_error() == Some(libc::EWOULDBLOCK)
}
pub(super) fn error_unsupported(err: &Error) -> bool {
match err.raw_os_error() {
// Unfortunately, depending on the target, these may or may not be the same.
// For targets in which they are the same, the duplicate pattern causes a warning.
#[allow(unreachable_patterns)]
Some(libc::ENOTSUP | libc::EOPNOTSUPP) => true,
Some(libc::ENOSYS) => true,
_ => false,
}
}
#[cfg(not(target_os = "solaris"))]
fn flock(file: &File, flag: libc::c_int) -> Result<()> {
let ret = unsafe { libc::flock(file.as_raw_fd(), flag) };
if ret < 0 {
Err(Error::last_os_error())
} else {
Ok(())
}
}
#[cfg(target_os = "solaris")]
fn flock(file: &File, flag: libc::c_int) -> Result<()> {
// Solaris lacks flock(), so simply succeed with a no-op
Ok(())
}
}
#[cfg(windows)]
mod sys {
use std::fs::File;
use std::io::{Error, Result};
use std::mem;
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Foundation::{ERROR_INVALID_FUNCTION, ERROR_LOCK_VIOLATION, HANDLE};
use windows_sys::Win32::Storage::FileSystem::{
LOCK_FILE_FLAGS, LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY, LockFileEx, UnlockFile,
};
pub(super) fn lock_shared(file: &File) -> Result<()> {
lock_file(file, 0)
}
pub(super) fn lock_exclusive(file: &File) -> Result<()> {
lock_file(file, LOCKFILE_EXCLUSIVE_LOCK)
}
pub(super) fn try_lock_shared(file: &File) -> Result<()> {
lock_file(file, LOCKFILE_FAIL_IMMEDIATELY)
}
pub(super) fn try_lock_exclusive(file: &File) -> Result<()> {
lock_file(file, LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY)
}
pub(super) fn error_contended(err: &Error) -> bool {
err.raw_os_error() == Some(ERROR_LOCK_VIOLATION as i32)
}
pub(super) fn error_unsupported(err: &Error) -> bool {
err.raw_os_error() == Some(ERROR_INVALID_FUNCTION as i32)
}
pub(super) fn unlock(file: &File) -> Result<()> {
let ret = unsafe { UnlockFile(file.as_raw_handle() as HANDLE, 0, 0, !0, !0) };
if ret == 0 {
Err(Error::last_os_error())
} else {
Ok(())
}
}
fn lock_file(file: &File, flags: LOCK_FILE_FLAGS) -> Result<()> {
let ret = unsafe {
let mut overlapped = mem::zeroed();
LockFileEx(
file.as_raw_handle() as HANDLE,
flags,
0,
!0,
!0,
&mut overlapped,
)
};
if ret == 0 {
Err(Error::last_os_error())
} else {
Ok(())
}
}
}

View File

@@ -0,0 +1,131 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::fmt;
#[derive(Debug, Clone)]
pub enum Framework {
SolidJS,
SolidStart,
Svelte,
SvelteKit,
Angular,
React,
Nextjs,
Gatsby,
Nuxt,
Quasar,
VueCli,
Vue,
}
impl Framework {
pub fn dev_url(&self) -> String {
match self {
Self::SolidJS => "http://localhost:3000",
Self::SolidStart => "http://localhost:3000",
Self::Svelte => "http://localhost:8080",
Self::SvelteKit => "http://localhost:5173",
Self::Angular => "http://localhost:4200",
Self::React => "http://localhost:3000",
Self::Nextjs => "http://localhost:3000",
Self::Gatsby => "http://localhost:8000",
Self::Nuxt => "http://localhost:3000",
Self::Quasar => "http://localhost:8080",
Self::VueCli => "http://localhost:8080",
Self::Vue => "http://localhost:8080",
}
.into()
}
pub fn frontend_dist(&self) -> String {
match self {
Self::SolidJS => "../dist",
Self::SolidStart => "../dist/public",
Self::Svelte => "../public",
Self::SvelteKit => "../build",
Self::Angular => "../dist",
Self::React => "../build",
Self::Nextjs => "../out",
Self::Gatsby => "../public",
Self::Nuxt => "../dist",
Self::Quasar => "../dist/spa",
Self::VueCli => "../dist",
Self::Vue => "../dist",
}
.into()
}
}
impl fmt::Display for Framework {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::SolidJS => write!(f, "SolidJS"),
Self::SolidStart => write!(f, "SolidStart"),
Self::Svelte => write!(f, "Svelte"),
Self::SvelteKit => write!(f, "SvelteKit"),
Self::Angular => write!(f, "Angular"),
Self::React => write!(f, "React"),
Self::Nextjs => write!(f, "React (Next.js)"),
Self::Gatsby => write!(f, "React (Gatsby)"),
Self::Nuxt => write!(f, "Vue.js (Nuxt)"),
Self::Quasar => write!(f, "Vue.js (Quasar)"),
Self::VueCli => write!(f, "Vue.js (Vue CLI)"),
Self::Vue => write!(f, "Vue.js"),
}
}
}
#[derive(Debug, Clone)]
pub enum Bundler {
Webpack,
Rollup,
Vite,
}
impl fmt::Display for Bundler {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Webpack => write!(f, "Webpack"),
Self::Rollup => write!(f, "Rollup"),
Self::Vite => write!(f, "Vite"),
}
}
}
pub fn infer_from_package_json(package_json: &str) -> (Option<Framework>, Option<Bundler>) {
let framework_map = [
("solid-start", Framework::SolidStart, Some(Bundler::Vite)),
("solid-js", Framework::SolidJS, Some(Bundler::Vite)),
("svelte", Framework::Svelte, Some(Bundler::Rollup)),
("@sveltejs/kit", Framework::SvelteKit, Some(Bundler::Vite)),
("@angular", Framework::Angular, Some(Bundler::Webpack)),
(r#""next""#, Framework::Nextjs, Some(Bundler::Webpack)),
("gatsby", Framework::Gatsby, Some(Bundler::Webpack)),
("react", Framework::React, None),
("nuxt", Framework::Nuxt, Some(Bundler::Webpack)),
("quasar", Framework::Quasar, Some(Bundler::Webpack)),
("@vue/cli", Framework::VueCli, Some(Bundler::Webpack)),
("vue", Framework::Vue, Some(Bundler::Vite)),
];
let bundler_map = [
("webpack", Bundler::Webpack),
("rollup", Bundler::Rollup),
("vite", Bundler::Vite),
];
let (framework, framework_bundler) = framework_map
.iter()
.find(|(keyword, _, _)| package_json.contains(keyword))
.map(|(_, framework, bundler)| (Some(framework.clone()), bundler.clone()))
.unwrap_or((None, None));
let bundler = bundler_map
.iter()
.find(|(keyword, _)| package_json.contains(keyword))
.map(|(_, bundler)| bundler.clone())
.or(framework_bundler);
(framework, bundler)
}

View File

@@ -0,0 +1,55 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::{
Error,
error::{Context, ErrorExt},
};
use std::path::{Path, PathBuf};
pub fn copy_file(from: impl AsRef<Path>, to: impl AsRef<Path>) -> crate::Result<()> {
let from = from.as_ref();
let to = to.as_ref();
if !from.exists() {
Err(Error::Fs {
context: "failed to copy file",
path: from.to_path_buf(),
error: std::io::Error::new(std::io::ErrorKind::NotFound, "source does not exist"),
})?;
}
if !from.is_file() {
Err(Error::Fs {
context: "failed to copy file",
path: from.to_path_buf(),
error: std::io::Error::other("not a file"),
})?;
}
let dest_dir = to.parent().expect("No data in parent");
std::fs::create_dir_all(dest_dir)
.fs_context("failed to create directory", dest_dir.to_path_buf())?;
std::fs::copy(from, to).fs_context("failed to copy file", from.to_path_buf())?;
Ok(())
}
/// Find an entry in a directory matching a glob pattern.
/// Currently does not traverse subdirectories.
// currently only used on macOS
#[allow(dead_code)]
pub fn find_in_directory(path: &Path, glob_pattern: &str) -> crate::Result<PathBuf> {
let pattern = glob::Pattern::new(glob_pattern)
.with_context(|| format!("failed to parse glob pattern {glob_pattern}"))?;
for entry in std::fs::read_dir(path)
.with_context(|| format!("failed to read directory {}", path.display()))?
{
let entry = entry.context("failed to read directory entry")?;
if pattern.matches_path(&entry.path()) {
return Ok(entry.path());
}
}
crate::error::bail!(
"No file found in {} matching {}",
path.display(),
glob_pattern
)
}

View File

@@ -0,0 +1,26 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use ureq::{Agent, Body, http::Response};
const CLI_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
pub fn get(url: &str) -> Result<Response<Body>, ureq::Error> {
#[allow(unused_mut)]
let mut config_builder = ureq::Agent::config_builder()
.user_agent(CLI_USER_AGENT)
.proxy(ureq::Proxy::try_from_env());
#[cfg(feature = "platform-certs")]
{
config_builder = config_builder.tls_config(
ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
.build(),
);
}
let agent: Agent = config_builder.build().into();
agent.get(url).call()
}

View File

@@ -0,0 +1,52 @@
{
"16x16": {
"name": "icon_16x16.png",
"size": 16,
"ostype": "is32"
},
"16x16@2x": {
"name": "icon_16x16@2x.png",
"size": 32,
"ostype": "ic11"
},
"32x32": {
"name": "icon_32x32.png",
"size": 32,
"ostype": "il32"
},
"32x32@2x": {
"name": "icon_32x32@2x.png",
"size": 64,
"ostype": "ic12"
},
"128x128": {
"name": "icon_128x128.png",
"size": 128,
"ostype": "ic07"
},
"128x128@2x": {
"name": "icon_128x128@2x.png",
"size": 256,
"ostype": "ic13"
},
"256x256": {
"name": "icon_256x256.png",
"size": 256,
"ostype": "ic08"
},
"256x256@2x": {
"name": "icon_256x256@2x.png",
"size": 512,
"ostype": "ic14"
},
"512x512": {
"name": "icon_512x512.png",
"size": 512,
"ostype": "ic09"
},
"512x512@2x": {
"name": "icon_512x512@2x.png",
"size": 1024,
"ostype": "ic10"
}
}

View File

@@ -0,0 +1,152 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
pub mod app_paths;
pub mod cargo;
pub mod cargo_manifest;
pub mod config;
pub mod flock;
pub mod framework;
pub mod fs;
pub mod http;
pub mod npm;
#[cfg(target_os = "macos")]
pub mod pbxproj;
#[cfg(target_os = "macos")]
pub mod plist;
pub mod plugins;
pub mod prompts;
pub mod template;
pub mod updater_signature;
use std::{
collections::HashMap,
path::{Path, PathBuf},
process::Command,
};
use tauri_utils::config::HookCommand;
#[cfg(not(target_os = "windows"))]
use crate::Error;
use crate::{CommandExt, interface::AppInterface};
pub fn command_env(debug: bool) -> HashMap<&'static str, String> {
let mut map = HashMap::new();
map.insert(
"TAURI_ENV_PLATFORM_VERSION",
os_info::get().version().to_string(),
);
if debug {
map.insert("TAURI_ENV_DEBUG", "true".into());
}
map
}
pub fn resolve_tauri_path<P: AsRef<Path>>(path: P, crate_name: &str) -> PathBuf {
let path = path.as_ref();
if path.is_absolute() {
path.join(crate_name)
} else {
PathBuf::from("..").join(path).join(crate_name)
}
}
pub fn cross_command(bin: &str) -> Command {
#[cfg(target_os = "windows")]
let cmd = {
let mut cmd = Command::new("cmd");
cmd.arg("/c").arg(bin);
cmd
};
#[cfg(not(target_os = "windows"))]
let cmd = Command::new(bin);
cmd
}
pub fn run_hook(
name: &str,
hook: HookCommand,
interface: &AppInterface,
debug: bool,
frontend_dir: &Path,
) -> crate::Result<()> {
let (script, script_cwd) = match hook {
HookCommand::Script(s) if s.is_empty() => (None, None),
HookCommand::Script(s) => (Some(s), None),
HookCommand::ScriptWithOptions { script, cwd } => (Some(script), cwd.map(Into::into)),
};
let cwd = script_cwd.unwrap_or_else(|| frontend_dir.to_owned());
if let Some(script) = script {
log::info!(action = "Running"; "{} `{}`", name, script);
let mut env = command_env(debug);
env.extend(interface.env());
log::debug!("Setting environment for hook {:?}", env);
#[cfg(target_os = "windows")]
let status = Command::new("cmd")
.arg("/S")
.arg("/C")
.arg(&script)
.current_dir(cwd)
.envs(env)
.piped()
.map_err(|error| crate::error::Error::CommandFailed {
command: script.clone(),
error,
})?;
#[cfg(not(target_os = "windows"))]
let status = Command::new("sh")
.arg("-c")
.arg(&script)
.current_dir(cwd)
.envs(env)
.piped()
.map_err(|error| Error::CommandFailed {
command: script.clone(),
error,
})?;
if !status.success() {
crate::error::bail!(
"{} `{}` failed with exit code {}",
name,
script,
status.code().unwrap_or_default()
);
}
}
Ok(())
}
#[cfg(target_os = "macos")]
pub fn strip_semver_prerelease_tag(version: &mut semver::Version) -> crate::Result<()> {
use crate::error::Context;
if !version.pre.is_empty() {
if let Some((_prerelease_tag, number)) = version.pre.as_str().to_string().split_once('.') {
version.pre = semver::Prerelease::EMPTY;
version.build = semver::BuildMetadata::new(&format!(
"{prefix}{number}",
prefix = if version.build.is_empty() {
"".to_string()
} else {
format!(".{}", version.build.as_str())
}
))
.with_context(|| {
format!(
"failed to parse {version} as semver: bundle version {number:?} prerelease is invalid"
)
})?;
}
}
Ok(())
}

View File

@@ -0,0 +1,459 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use serde::Deserialize;
use crate::{
error::{Context, Error},
helpers::cross_command,
};
use std::{collections::HashMap, fmt::Display, path::Path, process::Command};
pub fn manager_version(package_manager: &str) -> Option<String> {
cross_command(package_manager)
.arg("-v")
.output()
.map(|o| {
if o.status.success() {
let v = String::from_utf8_lossy(o.stdout.as_slice()).to_string();
Some(v.split('\n').next().unwrap().to_string())
} else {
None
}
})
.ok()
.unwrap_or_default()
}
fn detect_yarn_or_berry() -> PackageManager {
if manager_version("yarn")
.map(|v| v.chars().next().map(|c| c > '1').unwrap_or_default())
.unwrap_or(false)
{
PackageManager::YarnBerry
} else {
PackageManager::Yarn
}
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum PackageManager {
Npm,
Pnpm,
Yarn,
YarnBerry,
Bun,
Deno,
}
impl Display for PackageManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
PackageManager::Npm => "npm",
PackageManager::Pnpm => "pnpm",
PackageManager::Yarn => "yarn",
PackageManager::YarnBerry => "yarn berry",
PackageManager::Bun => "bun",
PackageManager::Deno => "deno",
}
)
}
}
impl PackageManager {
/// Detects package manager from the given directory, falls back to [`PackageManager::Npm`].
pub fn from_project<P: AsRef<Path>>(path: P) -> Self {
Self::all_from_project(path)
.first()
.copied()
.unwrap_or(Self::Npm)
}
/// Detects package manager from the `npm_config_user_agent` environment variable
fn from_environment_variable() -> Option<Self> {
let npm_config_user_agent = std::env::var("npm_config_user_agent").ok()?;
match npm_config_user_agent {
user_agent if user_agent.starts_with("pnpm/") => Some(Self::Pnpm),
user_agent if user_agent.starts_with("deno/") => Some(Self::Deno),
user_agent if user_agent.starts_with("bun/") => Some(Self::Bun),
user_agent if user_agent.starts_with("yarn/") => Some(detect_yarn_or_berry()),
user_agent if user_agent.starts_with("npm/") => Some(Self::Npm),
_ => None,
}
}
/// Detects all possible package managers from the given directory.
pub fn all_from_project<P: AsRef<Path>>(path: P) -> Vec<Self> {
if let Some(from_env) = Self::from_environment_variable() {
return vec![from_env];
}
let mut found = Vec::new();
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
let path = entry.path();
let name = path.file_name().unwrap().to_string_lossy();
match name.as_ref() {
"package-lock.json" => found.push(PackageManager::Npm),
"pnpm-lock.yaml" => found.push(PackageManager::Pnpm),
"yarn.lock" => found.push(detect_yarn_or_berry()),
"bun.lock" | "bun.lockb" => found.push(PackageManager::Bun),
"deno.lock" => found.push(PackageManager::Deno),
_ => (),
}
}
}
found
}
fn cross_command(&self) -> Command {
match self {
PackageManager::Yarn => cross_command("yarn"),
PackageManager::YarnBerry => cross_command("yarn"),
PackageManager::Npm => cross_command("npm"),
PackageManager::Pnpm => cross_command("pnpm"),
PackageManager::Bun => cross_command("bun"),
PackageManager::Deno => cross_command("deno"),
}
}
pub fn install<P: AsRef<Path>>(
&self,
dependencies: &[String],
frontend_dir: P,
) -> crate::Result<()> {
let dependencies_str = if dependencies.len() > 1 {
"dependencies"
} else {
"dependency"
};
log::info!(
"Installing NPM {dependencies_str} {}...",
dependencies
.iter()
.map(|d| format!("\"{d}\""))
.collect::<Vec<_>>()
.join(", ")
);
let mut command = self.cross_command();
command.arg("add");
match self {
PackageManager::Deno => command.args(dependencies.iter().map(|d| format!("npm:{d}"))),
_ => command.args(dependencies),
};
let status = command
.current_dir(frontend_dir)
.status()
.map_err(|error| Error::CommandFailed {
command: format!("failed to run {self}"),
error,
})?;
if !status.success() {
crate::error::bail!("Failed to install NPM {dependencies_str}");
}
Ok(())
}
pub fn remove<P: AsRef<Path>>(
&self,
dependencies: &[String],
frontend_dir: P,
) -> crate::Result<()> {
let dependencies_str = if dependencies.len() > 1 {
"dependencies"
} else {
"dependency"
};
log::info!(
"Removing NPM {dependencies_str} {}...",
dependencies
.iter()
.map(|d| format!("\"{d}\""))
.collect::<Vec<_>>()
.join(", ")
);
let status = self
.cross_command()
.arg(if *self == Self::Npm {
"uninstall"
} else {
"remove"
})
.args(dependencies)
.current_dir(frontend_dir)
.status()
.map_err(|error| Error::CommandFailed {
command: format!("failed to run {self}"),
error,
})?;
if !status.success() {
crate::error::bail!("Failed to remove NPM {dependencies_str}");
}
Ok(())
}
// TODO: Use `current_package_versions` as much as possible for better speed
pub fn current_package_version<P: AsRef<Path>>(
&self,
name: &str,
frontend_dir: P,
) -> crate::Result<Option<String>> {
let (output, regex) = match self {
PackageManager::Yarn => (
cross_command("yarn")
.args(["list", "--pattern"])
.arg(name)
.args(["--depth", "0"])
.current_dir(frontend_dir)
.output()
.map_err(|error| Error::CommandFailed {
command: "yarn list --pattern".to_string(),
error,
})?,
None,
),
PackageManager::YarnBerry => (
cross_command("yarn")
.arg("info")
.arg(name)
.arg("--json")
.current_dir(frontend_dir)
.output()
.map_err(|error| Error::CommandFailed {
command: "yarn info --json".to_string(),
error,
})?,
Some(regex::Regex::new("\"Version\":\"([\\da-zA-Z\\-\\.]+)\"").unwrap()),
),
PackageManager::Pnpm => (
cross_command("pnpm")
.arg("list")
.arg(name)
.args(["--parseable", "--depth", "0"])
.current_dir(frontend_dir)
.output()
.map_err(|error| Error::CommandFailed {
command: "pnpm list --parseable --depth 0".to_string(),
error,
})?,
None,
),
// Bun and Deno don't support `list` command
PackageManager::Npm | PackageManager::Bun | PackageManager::Deno => (
cross_command("npm")
.arg("list")
.arg(name)
.args(["version", "--depth", "0"])
.current_dir(frontend_dir)
.output()
.map_err(|error| Error::CommandFailed {
command: "npm list --version --depth 0".to_string(),
error,
})?,
None,
),
};
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let regex = regex.unwrap_or_else(|| regex::Regex::new("@(\\d[\\da-zA-Z\\-\\.]+)").unwrap());
Ok(
regex
.captures_iter(&stdout)
.last()
.and_then(|cap| cap.get(1).map(|v| v.as_str().to_string())),
)
} else {
Ok(None)
}
}
pub fn current_package_versions(
&self,
packages: &[String],
frontend_dir: &Path,
) -> crate::Result<HashMap<String, semver::Version>> {
let output = match self {
PackageManager::Yarn => return yarn_package_versions(packages, frontend_dir),
PackageManager::YarnBerry => return yarn_berry_package_versions(packages, frontend_dir),
PackageManager::Pnpm => cross_command("pnpm")
.arg("list")
.args(packages)
.args(["--json", "--depth", "0"])
.current_dir(frontend_dir)
.output()
.map_err(|error| Error::CommandFailed {
command: "pnpm list --json --depth 0".to_string(),
error,
})?,
// Bun and Deno don't support `list` command
PackageManager::Npm | PackageManager::Bun | PackageManager::Deno => cross_command("npm")
.arg("list")
.args(packages)
.args(["--json", "--depth", "0"])
.current_dir(frontend_dir)
.output()
.map_err(|error| Error::CommandFailed {
command: "npm list --json --depth 0".to_string(),
error,
})?,
};
let mut versions = HashMap::new();
let stdout = String::from_utf8_lossy(&output.stdout);
if !output.status.success() {
return Ok(versions);
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ListOutput {
#[serde(default)]
dependencies: HashMap<String, ListDependency>,
#[serde(default)]
dev_dependencies: HashMap<String, ListDependency>,
}
#[derive(Deserialize)]
struct ListDependency {
version: String,
}
let json = if matches!(self, PackageManager::Pnpm) {
serde_json::from_str::<Vec<ListOutput>>(&stdout)
.ok()
.and_then(|out| out.into_iter().next())
.context("failed to parse pnpm list")?
} else {
serde_json::from_str::<ListOutput>(&stdout).context("failed to parse npm list")?
};
for (package, dependency) in json.dependencies.into_iter().chain(json.dev_dependencies) {
let version = dependency.version;
if let Ok(version) = semver::Version::parse(&version) {
versions.insert(package, version);
} else {
log::debug!("Failed to parse version `{version}` for NPM package `{package}`");
}
}
Ok(versions)
}
}
fn yarn_package_versions(
packages: &[String],
frontend_dir: &Path,
) -> crate::Result<HashMap<String, semver::Version>> {
let output = cross_command("yarn")
.arg("list")
.args(packages)
.args(["--json", "--depth", "0"])
.current_dir(frontend_dir)
.output()
.map_err(|error| Error::CommandFailed {
command: "yarn list --json --depth 0".to_string(),
error,
})?;
let mut versions = HashMap::new();
let stdout = String::from_utf8_lossy(&output.stdout);
if !output.status.success() {
return Ok(versions);
}
#[derive(Deserialize)]
struct YarnListOutput {
data: YarnListOutputData,
}
#[derive(Deserialize)]
struct YarnListOutputData {
trees: Vec<YarnListOutputDataTree>,
}
#[derive(Deserialize)]
struct YarnListOutputDataTree {
name: String,
}
for line in stdout.lines() {
if let Ok(tree) = serde_json::from_str::<YarnListOutput>(line) {
for tree in tree.data.trees {
let Some((name, version)) = tree.name.rsplit_once('@') else {
continue;
};
if let Ok(version) = semver::Version::parse(version) {
versions.insert(name.to_owned(), version);
} else {
log::debug!("Failed to parse version `{version}` for NPM package `{name}`");
}
}
return Ok(versions);
}
}
Ok(versions)
}
fn yarn_berry_package_versions(
packages: &[String],
frontend_dir: &Path,
) -> crate::Result<HashMap<String, semver::Version>> {
let output = cross_command("yarn")
.args(["info", "--json"])
.current_dir(frontend_dir)
.output()
.map_err(|error| Error::CommandFailed {
command: "yarn info --json".to_string(),
error,
})?;
let mut versions = HashMap::new();
let stdout = String::from_utf8_lossy(&output.stdout);
if !output.status.success() {
return Ok(versions);
}
#[derive(Deserialize)]
struct YarnBerryInfoOutput {
value: String,
children: YarnBerryInfoOutputChildren,
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct YarnBerryInfoOutputChildren {
version: String,
}
for line in stdout.lines() {
if let Ok(info) = serde_json::from_str::<YarnBerryInfoOutput>(line) {
let Some((name, _)) = info.value.rsplit_once('@') else {
continue;
};
if !packages.iter().any(|package| package == name) {
continue;
}
let version = info.children.version;
if let Ok(version) = semver::Version::parse(&version) {
versions.insert(name.to_owned(), version);
} else {
log::debug!("Failed to parse version `{version}` for NPM package `{name}`");
}
}
}
Ok(versions)
}

View File

@@ -0,0 +1,327 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::{
collections::BTreeMap,
fmt,
path::{Path, PathBuf},
};
use crate::error::ErrorExt;
pub fn parse<P: AsRef<Path>>(path: P) -> crate::Result<Pbxproj> {
let path = path.as_ref();
let pbxproj =
std::fs::read_to_string(path).fs_context("failed to read pbxproj file", path.to_path_buf())?;
let mut proj = Pbxproj {
path: path.to_owned(),
raw_lines: pbxproj.split('\n').map(ToOwned::to_owned).collect(),
xc_build_configuration: BTreeMap::new(),
xc_configuration_list: BTreeMap::new(),
additions: BTreeMap::new(),
has_changes: false,
};
let mut state = State::Idle;
let mut iter = proj.raw_lines.iter().enumerate();
while let Some((line_number, line)) = iter.next() {
match &state {
State::Idle => {
if line == "/* Begin XCBuildConfiguration section */" {
state = State::XCBuildConfiguration;
} else if line == "/* Begin XCConfigurationList section */" {
state = State::XCConfigurationList;
}
}
// XCBuildConfiguration
State::XCBuildConfiguration => {
if line == "/* End XCBuildConfiguration section */" {
state = State::Idle;
} else if let Some((_identation, token)) = split_at_identation(line) {
let id: String = token.chars().take_while(|c| !c.is_whitespace()).collect();
proj.xc_build_configuration.insert(
id.clone(),
XCBuildConfiguration {
build_settings: Vec::new(),
},
);
state = State::XCBuildConfigurationObject { id };
}
}
State::XCBuildConfigurationObject { id } => {
if line.contains("buildSettings") {
state = State::XCBuildConfigurationObjectBuildSettings { id: id.clone() };
} else if split_at_identation(line).is_some_and(|(_ident, token)| token == "};") {
state = State::XCBuildConfiguration;
}
}
State::XCBuildConfigurationObjectBuildSettings { id } => {
if let Some((identation, token)) = split_at_identation(line) {
if token == "};" {
state = State::XCBuildConfigurationObject { id: id.clone() };
} else {
let assignment = token.trim_end_matches(';');
if let Some((key, value)) = assignment.split_once(" = ") {
// multiline value
let value = if value == "(" {
let mut value = value.to_string();
for (_next_line_number, next_line) in iter.by_ref() {
value.push_str(next_line);
value.push('\n');
if let Some((_, token)) = split_at_identation(next_line) {
if token == ");" {
break;
}
}
}
value
} else {
value.trim().to_string()
};
proj
.xc_build_configuration
.get_mut(id)
.unwrap()
.build_settings
.push(BuildSettings {
identation: identation.into(),
line_number,
key: key.trim().into(),
value,
});
}
}
}
}
// XCConfigurationList
State::XCConfigurationList => {
if line == "/* End XCConfigurationList section */" {
state = State::Idle;
} else if let Some((_identation, token)) = split_at_identation(line) {
let Some((id, comment)) = token.split_once(' ') else {
continue;
};
proj.xc_configuration_list.insert(
id.to_string(),
XCConfigurationList {
comment: comment.trim_end_matches(" = {").to_string(),
build_configurations: Vec::new(),
},
);
state = State::XCConfigurationListObject { id: id.to_string() };
}
}
State::XCConfigurationListObject { id } => {
if line.contains("buildConfigurations") {
state = State::XCConfigurationListObjectBuildConfigurations { id: id.clone() };
} else if split_at_identation(line).is_some_and(|(_ident, token)| token == "};") {
state = State::XCConfigurationList;
}
}
State::XCConfigurationListObjectBuildConfigurations { id } => {
if let Some((_identation, token)) = split_at_identation(line) {
if token == ");" {
state = State::XCConfigurationListObject { id: id.clone() };
} else {
let Some((build_configuration_id, comments)) = token.split_once(' ') else {
continue;
};
proj
.xc_configuration_list
.get_mut(id)
.unwrap()
.build_configurations
.push(BuildConfigurationRef {
id: build_configuration_id.to_string(),
comments: comments.trim_end_matches(',').to_string(),
});
}
}
}
}
}
Ok(proj)
}
fn split_at_identation(s: &str) -> Option<(&str, &str)> {
s.chars()
.position(|c| !c.is_ascii_whitespace())
.map(|pos| s.split_at(pos))
}
enum State {
Idle,
// XCBuildConfiguration
XCBuildConfiguration,
XCBuildConfigurationObject { id: String },
XCBuildConfigurationObjectBuildSettings { id: String },
// XCConfigurationList
XCConfigurationList,
XCConfigurationListObject { id: String },
XCConfigurationListObjectBuildConfigurations { id: String },
}
pub struct Pbxproj {
pub path: PathBuf,
raw_lines: Vec<String>,
pub xc_build_configuration: BTreeMap<String, XCBuildConfiguration>,
pub xc_configuration_list: BTreeMap<String, XCConfigurationList>,
// maps the line number to the line to add
additions: BTreeMap<usize, String>,
has_changes: bool,
}
impl fmt::Debug for Pbxproj {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Pbxproj")
.field("xc_build_configuration", &self.xc_build_configuration)
.field("xc_configuration_list", &self.xc_configuration_list)
.finish()
}
}
impl Pbxproj {
pub fn has_changes(&self) -> bool {
!self.additions.is_empty() || self.has_changes
}
fn serialize(&self) -> String {
let mut proj = String::new();
let last_line_number = self.raw_lines.len() - 1;
for (number, line) in self.raw_lines.iter().enumerate() {
if let Some(new) = self.additions.get(&number) {
proj.push_str(new);
proj.push('\n');
}
proj.push_str(line);
if number != last_line_number {
proj.push('\n');
}
}
proj
}
pub fn save(&self) -> std::io::Result<()> {
std::fs::write(&self.path, self.serialize())
}
pub fn set_build_settings(&mut self, build_configuration_id: &str, key: &str, value: &str) {
let Some(build_configuration) = self.xc_build_configuration.get_mut(build_configuration_id)
else {
return;
};
if let Some(build_setting) = build_configuration
.build_settings
.iter_mut()
.find(|s| s.key == key)
{
if build_setting.value != value {
let Some(line) = self.raw_lines.get_mut(build_setting.line_number) else {
return;
};
*line = format!("{}{key} = {value};", build_setting.identation);
self.has_changes = true;
}
} else {
let Some(last_build_setting) = build_configuration.build_settings.last().cloned() else {
return;
};
build_configuration.build_settings.push(BuildSettings {
identation: last_build_setting.identation.clone(),
line_number: last_build_setting.line_number + 1,
key: key.to_string(),
value: value.to_string(),
});
self.additions.insert(
last_build_setting.line_number + 1,
format!("{}{key} = {value};", last_build_setting.identation),
);
}
}
}
#[derive(Debug)]
pub struct XCBuildConfiguration {
build_settings: Vec<BuildSettings>,
}
impl XCBuildConfiguration {
pub fn get_build_setting(&self, key: &str) -> Option<&BuildSettings> {
self.build_settings.iter().find(|s| s.key == key)
}
}
#[derive(Debug, Clone)]
pub struct BuildSettings {
identation: String,
line_number: usize,
pub key: String,
pub value: String,
}
#[derive(Debug, Clone)]
pub struct XCConfigurationList {
pub comment: String,
pub build_configurations: Vec<BuildConfigurationRef>,
}
#[derive(Debug, Clone)]
pub struct BuildConfigurationRef {
pub id: String,
pub comments: String,
}
#[cfg(test)]
mod tests {
#[test]
fn parse() {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let fixtures_path = manifest_dir.join("tests").join("fixtures").join("pbxproj");
let mut settings = insta::Settings::clone_current();
settings.set_snapshot_path(fixtures_path.join("snapshots"));
let _guard = settings.bind_to_scope();
insta::assert_debug_snapshot!(
"project.pbxproj",
super::parse(fixtures_path.join("project.pbxproj")).expect("failed to parse pbxproj")
);
}
#[test]
fn modify() {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let fixtures_path = manifest_dir.join("tests").join("fixtures").join("pbxproj");
let mut settings = insta::Settings::clone_current();
settings.set_snapshot_path(fixtures_path.join("snapshots"));
let _guard = settings.bind_to_scope();
let mut pbxproj =
super::parse(fixtures_path.join("project.pbxproj")).expect("failed to parse pbxproj");
pbxproj.set_build_settings(
"DB_0E254D0FD84970B57F6410",
"PRODUCT_NAME",
"\"Tauri Test\"",
);
pbxproj.set_build_settings("DB_0E254D0FD84970B57F6410", "UNKNOWN", "9283j49238h");
insta::assert_snapshot!("project-modified.pbxproj", pbxproj.serialize());
}
}

View File

@@ -0,0 +1,42 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::path::PathBuf;
use crate::error::Context;
pub enum PlistKind {
Path(PathBuf),
Plist(plist::Value),
}
impl From<PathBuf> for PlistKind {
fn from(p: PathBuf) -> Self {
Self::Path(p)
}
}
impl From<plist::Value> for PlistKind {
fn from(p: plist::Value) -> Self {
Self::Plist(p)
}
}
pub fn merge_plist(src: Vec<PlistKind>) -> crate::Result<plist::Value> {
let mut merged_plist = plist::Dictionary::new();
for plist_kind in src {
let src_plist = match plist_kind {
PlistKind::Path(p) => plist::Value::from_file(&p)
.with_context(|| format!("failed to parse plist from {}", p.display()))?,
PlistKind::Plist(v) => v,
};
if let Some(dict) = src_plist.into_dictionary() {
for (key, value) in dict {
merged_plist.insert(key, value);
}
}
}
Ok(plist::Value::Dictionary(merged_plist))
}

View File

@@ -0,0 +1,98 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::collections::HashMap;
#[derive(Default)]
pub struct PluginMetadata {
pub desktop_only: bool,
pub mobile_only: bool,
pub rust_only: bool,
pub builder: bool,
pub version_req: Option<String>,
}
// known plugins with particular cases
pub fn known_plugins() -> HashMap<&'static str, PluginMetadata> {
let mut plugins: HashMap<&'static str, PluginMetadata> = HashMap::new();
// desktop-only
for p in [
"authenticator",
"autostart",
"cli",
"global-shortcut",
"positioner",
"single-instance",
"updater",
"window-state",
] {
plugins.entry(p).or_default().desktop_only = true;
}
// mobile-only
for p in ["barcode-scanner", "biometric", "nfc", "haptics"] {
plugins.entry(p).or_default().mobile_only = true;
}
// uses builder pattern
for p in [
"autostart",
"global-shortcut",
"localhost",
"log",
"sql",
"store",
"stronghold",
"updater",
"window-state",
] {
plugins.entry(p).or_default().builder = true;
}
// rust-only
#[allow(clippy::single_element_loop)]
for p in ["localhost", "persisted-scope", "single-instance"] {
plugins.entry(p).or_default().rust_only = true;
}
// known, but no particular config
for p in [
"geolocation",
"deep-link",
"dialog",
"fs",
"http",
"notification",
"os",
"process",
"shell",
"upload",
"websocket",
"opener",
"clipboard-manager",
] {
plugins.entry(p).or_default();
}
let version_req = version_req();
for plugin in plugins.values_mut() {
plugin.version_req.replace(version_req.clone());
}
plugins
}
fn version_req() -> String {
let pre = env!("CARGO_PKG_VERSION_PRE");
if pre.is_empty() {
env!("CARGO_PKG_VERSION_MAJOR").to_string()
} else {
format!(
"{}.0.0-{}",
env!("CARGO_PKG_VERSION_MAJOR"),
pre.split('.').next().unwrap()
)
}
}

View File

@@ -0,0 +1,61 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::{fmt::Display, str::FromStr};
use crate::{Result, error::Context};
pub fn input<T>(
prompt: &str,
initial: Option<T>,
skip: bool,
allow_empty: bool,
) -> Result<Option<T>>
where
T: Clone + FromStr + Display + ToString,
T::Err: Display + std::fmt::Debug,
T: PartialEq<str>,
{
if skip {
Ok(initial)
} else {
let theme = dialoguer::theme::ColorfulTheme::default();
let mut builder = dialoguer::Input::with_theme(&theme)
.with_prompt(prompt)
.allow_empty(allow_empty);
if let Some(v) = initial {
builder = builder.with_initial_text(v.to_string());
}
builder
.interact_text()
.map(|t: T| if t.ne("") { Some(t) } else { None })
.context("failed to prompt input")
}
}
pub fn confirm(prompt: &str, default: Option<bool>) -> Result<bool> {
let theme = dialoguer::theme::ColorfulTheme::default();
let mut builder = dialoguer::Confirm::with_theme(&theme).with_prompt(prompt);
if let Some(default) = default {
builder = builder.default(default);
}
builder.interact().context("failed to prompt confirm")
}
pub fn multiselect<T: ToString, I: IntoIterator<Item = T>>(
prompt: &str,
items: I,
defaults: Option<&[bool]>,
) -> Result<Vec<usize>> {
let theme = dialoguer::theme::ColorfulTheme::default();
let mut builder = dialoguer::MultiSelect::with_theme(&theme)
.with_prompt(prompt)
.items(items);
if let Some(defaults) = defaults {
builder = builder.defaults(defaults);
}
builder.interact().context("failed to prompt multi-select")
}

View File

@@ -0,0 +1,97 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::{
fs::{File, create_dir_all},
io::Write,
path::{Path, PathBuf},
};
use handlebars::{Handlebars, to_json};
use include_dir::Dir;
use serde::Serialize;
use serde_json::value::{Map, Value as JsonValue};
use crate::error::ErrorExt;
/// Map of template variable names and values.
#[derive(Clone, Debug)]
#[repr(transparent)]
pub struct JsonMap(Map<String, JsonValue>);
impl Default for JsonMap {
fn default() -> Self {
Self(Map::new())
}
}
impl JsonMap {
pub fn insert(&mut self, name: &str, value: impl Serialize) {
self.0.insert(name.to_owned(), to_json(value));
}
pub fn inner(&self) -> &Map<String, JsonValue> {
&self.0
}
}
pub fn render<P: AsRef<Path>, D: Serialize>(
handlebars: &Handlebars<'_>,
data: &D,
dir: &Dir<'_>,
out_dir: P,
) -> crate::Result<()> {
let out_dir = out_dir.as_ref();
let mut created_dirs = Vec::new();
render_with_generator(handlebars, data, dir, out_dir, &mut |file_path: PathBuf| {
let path = out_dir.join(file_path);
let parent = path.parent().unwrap().to_path_buf();
if !created_dirs.contains(&parent) {
create_dir_all(&parent)?;
created_dirs.push(parent);
}
File::create(path).map(Some)
})
}
pub fn render_with_generator<
P: AsRef<Path>,
D: Serialize,
F: FnMut(PathBuf) -> std::io::Result<Option<File>>,
>(
handlebars: &Handlebars<'_>,
data: &D,
dir: &Dir<'_>,
out_dir: P,
out_file_generator: &mut F,
) -> crate::Result<()> {
let out_dir = out_dir.as_ref();
for file in dir.files() {
let mut file_path = file.path().to_path_buf();
// cargo for some reason ignores the /templates folder packaging when it has a Cargo.toml file inside
// so we rename the extension to `.crate-manifest`
if let Some(extension) = file_path.extension() {
if extension == "crate-manifest" {
file_path.set_extension("toml");
}
}
if let Some(mut output_file) = out_file_generator(file_path.clone())
.fs_context("failed to generate output file", file_path.clone())?
{
if let Some(utf8) = file.contents_utf8() {
handlebars
.render_template_to_write(utf8, &data, &mut output_file)
.expect("Failed to render template");
} else {
output_file
.write_all(file.contents())
.fs_context("failed to write template", file_path.clone())?;
}
}
}
for dir in dir.dirs() {
render_with_generator(handlebars, data, dir, out_dir, out_file_generator)?;
}
Ok(())
}

View File

@@ -0,0 +1,297 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use base64::Engine;
use minisign::{
KeyPair as KP, PublicKey, PublicKeyBox, SecretKey, SecretKeyBox, SignatureBox, sign,
};
use std::{
fs::{self, File, OpenOptions},
io::{BufReader, BufWriter, Write},
path::{Path, PathBuf},
str,
time::{SystemTime, UNIX_EPOCH},
};
use crate::error::{Context, ErrorExt};
/// A key pair (`PublicKey` and `SecretKey`).
#[derive(Clone, Debug)]
pub struct KeyPair {
pub pk: String,
pub sk: String,
}
fn create_file(path: &Path) -> crate::Result<BufWriter<File>> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).fs_context("failed to create directory", parent.to_path_buf())?;
}
let file = File::create(path).fs_context("failed to create file", path.to_path_buf())?;
Ok(BufWriter::new(file))
}
/// Generate base64 encoded keypair
pub fn generate_key(password: Option<String>) -> crate::Result<KeyPair> {
let KP { pk, sk } = KP::generate_encrypted_keypair(password).unwrap();
let pk_box_str = pk.to_box().unwrap().to_string();
let sk_box_str = sk.to_box(None).unwrap().to_string();
let encoded_pk = base64::engine::general_purpose::STANDARD.encode(pk_box_str);
let encoded_sk = base64::engine::general_purpose::STANDARD.encode(sk_box_str);
Ok(KeyPair {
pk: encoded_pk,
sk: encoded_sk,
})
}
/// Transform a base64 String to readable string for the main signer
pub fn decode_key<S: AsRef<[u8]>>(base64_key: S) -> crate::Result<String> {
let decoded_str = &base64::engine::general_purpose::STANDARD
.decode(base64_key)
.context("failed to decode base64 key")?[..];
Ok(String::from(
str::from_utf8(decoded_str).context("failed to convert base64 to utf8")?,
))
}
/// Save KeyPair to disk
pub fn save_keypair<P>(
force: bool,
sk_path: P,
key: &str,
pubkey: &str,
) -> crate::Result<(PathBuf, PathBuf)>
where
P: AsRef<Path>,
{
let sk_path = sk_path.as_ref();
let pubkey_path = format!("{}.pub", sk_path.display());
let pk_path = Path::new(&pubkey_path);
if sk_path.exists() {
if !force {
crate::error::bail!(
"Key generation aborted:\n{} already exists\nIf you really want to overwrite the existing key pair, add the --force switch to force this operation.",
sk_path.display()
);
} else {
std::fs::remove_file(sk_path)
.fs_context("failed to remove secret key file", sk_path.to_path_buf())?;
}
}
if pk_path.exists() {
std::fs::remove_file(pk_path)
.fs_context("failed to remove public key file", pk_path.to_path_buf())?;
}
let write_file = |mut writer: BufWriter<File>, contents: &str| -> std::io::Result<()> {
write!(writer, "{contents:}")?;
writer.flush()?;
Ok(())
};
write_file(create_file(sk_path)?, key)
.fs_context("failed to write secret key", sk_path.to_path_buf())?;
write_file(create_file(pk_path)?, pubkey)
.fs_context("failed to write public key", pk_path.to_path_buf())?;
Ok((
fs::canonicalize(sk_path).fs_context(
"failed to canonicalize secret key path",
sk_path.to_path_buf(),
)?,
fs::canonicalize(pk_path).fs_context(
"failed to canonicalize public key path",
pk_path.to_path_buf(),
)?,
))
}
/// Sign files
///
/// When `version` is given it is embedded in the signature's trusted comment. minisign covers
/// the trusted comment with its global signature, so this binds the signed artifact to the
/// version it was released as. The update manifest is not itself signed, so without this the
/// manifest's `version` field can be paired with an older release's URL and signature to force
/// a downgrade. See the `requireSignedVersion` updater config option.
pub fn sign_file<P>(
secret_key: &SecretKey,
bin_path: P,
version: Option<&str>,
) -> crate::Result<(PathBuf, SignatureBox)>
where
P: AsRef<Path>,
{
let bin_path = bin_path.as_ref();
// We need to append .sig at the end it's where the signature will be stored
// TODO: use `with_added_extension` when we bump MSRV to >= 1.91
let signature_path = if let Some(ext) = bin_path.extension() {
let mut extension = ext.to_os_string();
extension.push(".sig");
bin_path.with_extension(extension)
} else {
bin_path.with_extension("sig")
};
let mut trusted_comment = format!(
"timestamp:{}\tfile:{}",
unix_timestamp(),
bin_path.file_name().unwrap().to_string_lossy()
);
if let Some(version) = version {
// the trusted comment is a single line of tab separated fields, so a version carrying
// either separator would produce a signature we cannot parse back
if version.contains(['\t', '\r', '\n']) {
crate::error::bail!(
"the app version {version:?} cannot be signed because it contains a tab or newline"
);
}
// appended last so anything parsing the historical `timestamp:...\tfile:...` prefix keeps working
trusted_comment.push_str("\tversion:");
trusted_comment.push_str(version);
}
let data_reader = open_data_file(bin_path)?;
let signature_box = sign(
None,
secret_key,
data_reader,
Some(trusted_comment.as_str()),
Some("signature from tauri secret key"),
)
.context("failed to sign file")?;
let encoded_signature =
base64::engine::general_purpose::STANDARD.encode(signature_box.to_string());
std::fs::write(&signature_path, encoded_signature.as_bytes())
.fs_context("failed to write signature file", signature_path.clone())?;
Ok((
fs::canonicalize(&signature_path)
.fs_context("failed to canonicalize signature file", &signature_path)?,
signature_box,
))
}
/// Gets the updater secret key from the given private key and password.
///
/// If `password` is `None`, a password is going to be prompted interactively.
pub fn secret_key<S: AsRef<[u8]>>(
private_key: S,
password: Option<String>,
) -> crate::Result<SecretKey> {
let decoded_secret = decode_key(private_key).context("failed to decode base64 secret key")?;
let sk_box =
SecretKeyBox::from_string(&decoded_secret).context("failed to load updater private key")?;
let sk = sk_box
.into_secret_key(password)
.context("incorrect updater private key password")?;
Ok(sk)
}
/// Gets the updater secret key from the given private key and password.
pub fn pub_key<S: AsRef<[u8]>>(public_key: S) -> crate::Result<PublicKey> {
let decoded_publick = decode_key(public_key).context("failed to decode base64 pubkey")?;
let pk_box =
PublicKeyBox::from_string(&decoded_publick).context("failed to load updater pubkey")?;
let pk = pk_box
.into_public_key()
.context("failed to convert updater pubkey")?;
Ok(pk)
}
fn unix_timestamp() -> u64 {
let start = SystemTime::now();
let since_the_epoch = start
.duration_since(UNIX_EPOCH)
.expect("system clock is incorrect");
since_the_epoch.as_secs()
}
fn open_data_file<P>(data_path: P) -> crate::Result<BufReader<File>>
where
P: AsRef<Path>,
{
let data_path = data_path.as_ref();
let file = OpenOptions::new()
.read(true)
.open(data_path)
.fs_context("failed to open data file", data_path.to_path_buf())?;
Ok(BufReader::new(file))
}
#[cfg(test)]
mod tests {
use super::*;
// This was encrypted with an empty string
const PRIVATE_KEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5dkpDN09RZm5GeVAzc2RuYlNzWVVJelJRQnNIV2JUcGVXZUplWXZXYXpqUUFBQkFBQUFBQUFBQUFBQUlBQUFBQTZrN2RnWGh5dURxSzZiL1ZQSDdNcktiaHRxczQwMXdQelRHbjRNcGVlY1BLMTBxR2dpa3I3dDE1UTVDRDE4MXR4WlQwa1BQaXdxKy9UU2J2QmVSNXhOQWFDeG1GSVllbUNpTGJQRkhhTnROR3I5RmdUZi90OGtvaGhJS1ZTcjdZU0NyYzhQWlQ5cGM9Cg==";
// minisign >=0.7.4,<0.8.0 couldn't handle empty passwords if the private key is encrypted with an empty string.
#[test]
fn empty_password_is_valid() {
let path = std::env::temp_dir().join("minisign-password-text.txt");
std::fs::write(&path, b"TAURI").expect("failed to write test file");
let secret_key =
secret_key(PRIVATE_KEY, Some("".into())).expect("failed to resolve secret key");
sign_file(&secret_key, &path, None).expect("failed to sign file");
}
#[test]
fn embeds_version_in_trusted_comment() {
let path = std::env::temp_dir().join("minisign-versioned-text.txt");
std::fs::write(&path, b"TAURI").expect("failed to write test file");
let secret_key =
secret_key(PRIVATE_KEY, Some("".into())).expect("failed to resolve secret key");
let (_, signature) = sign_file(&secret_key, &path, Some("1.2.3")).expect("failed to sign file");
let trusted_comment = signature
.trusted_comment()
.expect("failed to read trusted comment");
assert!(
trusted_comment.ends_with("\tversion:1.2.3"),
"unexpected trusted comment: {trusted_comment}"
);
// the historical prefix must stay intact so older consumers keep parsing it
assert!(trusted_comment.starts_with("timestamp:"));
assert!(trusted_comment.contains("\tfile:minisign-versioned-text.txt\t"));
let (_, signature) = sign_file(&secret_key, &path, None).expect("failed to sign file");
assert!(
!signature
.trusted_comment()
.expect("failed to read trusted comment")
.contains("version:")
);
}
#[test]
fn rejects_version_that_breaks_the_trusted_comment() {
let path = std::env::temp_dir().join("minisign-invalid-version-text.txt");
std::fs::write(&path, b"TAURI").expect("failed to write test file");
let secret_key =
secret_key(PRIVATE_KEY, Some("".into())).expect("failed to resolve secret key");
assert!(sign_file(&secret_key, &path, Some("1.0.0\ttampered")).is_err());
assert!(sign_file(&secret_key, &path, Some("1.0.0\ntampered")).is_err());
}
// This tests the newly generated keys with empty string password works
// minisign >=0.7.4,<=0.8.0 generate keys unencrypted if the password is empty but is marked encrypted hence unusable
#[test]
fn generate_empty_password_keys_and_use() {
let KeyPair { pk, sk } = generate_key(Some("".to_owned())).unwrap();
let pk = pub_key(pk).unwrap();
let sk = secret_key(sk, Some("".into())).unwrap();
let data = b"TAURI".as_slice();
sign(Some(&pk), &sk, data, None, None).expect("failed to sign file");
}
}

1160
crates/tauri-cli/src/icon.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use super::SectionItem;
use crate::helpers::config::ConfigMetadata;
use crate::helpers::framework;
use std::{fs::read_to_string, path::PathBuf};
pub fn items(config: &ConfigMetadata, frontend_dir: Option<&PathBuf>) -> Vec<SectionItem> {
let mut items = Vec::new();
let bundle_or_build = if config.bundle.active {
"bundle"
} else {
"build"
};
items.push(SectionItem::new().description(format!("build-type: {bundle_or_build}")));
let csp = config
.app
.security
.csp
.clone()
.map(|c| c.to_string())
.unwrap_or_else(|| "unset".to_string());
items.push(SectionItem::new().description(format!("CSP: {csp}")));
if let Some(frontend_dist) = &config.build.frontend_dist {
items.push(SectionItem::new().description(format!("frontendDist: {frontend_dist}")));
}
if let Some(dev_url) = &config.build.dev_url {
items.push(SectionItem::new().description(format!("devUrl: {dev_url}")));
}
if let Some(frontend_dir) = frontend_dir {
if let Ok(package_json) = read_to_string(frontend_dir.join("package.json")) {
let (framework, bundler) = framework::infer_from_package_json(&package_json);
if let Some(framework) = framework {
items.push(SectionItem::new().description(format!("framework: {framework}")));
}
if let Some(bundler) = bundler {
items.push(SectionItem::new().description(format!("bundler: {bundler}")));
}
}
}
items
}

View File

@@ -0,0 +1,55 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use super::{ActionResult, SectionItem, VersionMetadata};
use colored::Colorize;
use crate::helpers::{cross_command, npm::manager_version};
pub fn items(metadata: &VersionMetadata) -> Vec<SectionItem> {
let node_target_ver = metadata.js_cli.node.replace(">= ", "");
vec![
SectionItem::new().action(move || {
cross_command("node")
.arg("-v")
.output()
.map(|o| {
if o.status.success() {
let v = String::from_utf8_lossy(o.stdout.as_slice()).to_string();
let v = v
.split('\n')
.next()
.unwrap()
.strip_prefix('v')
.unwrap_or_default()
.trim();
ActionResult::Description(format!("node: {}{}", v, {
let version = semver::Version::parse(v);
let target_version = semver::Version::parse(node_target_ver.as_str());
match (version, target_version) {
(Ok(version), Ok(target_version)) if version < target_version => {
format!(
" ({}, latest: {})",
"outdated".red(),
target_version.to_string().green()
)
}
_ => "".into(),
}
}))
} else {
ActionResult::None
}
})
.ok()
.unwrap_or_default()
}),
SectionItem::new().action(|| manager_version("pnpm").map(|v| format!("pnpm: {v}")).into()),
SectionItem::new().action(|| manager_version("yarn").map(|v| format!("yarn: {v}")).into()),
SectionItem::new().action(|| manager_version("npm").map(|v| format!("npm: {v}")).into()),
SectionItem::new().action(|| manager_version("bun").map(|v| format!("bun: {v}")).into()),
SectionItem::new().action(|| manager_version("deno").map(|v| format!("deno: {v}")).into()),
]
}

View File

@@ -0,0 +1,95 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use super::SectionItem;
use super::Status;
use colored::Colorize;
use std::process::Command;
fn component_version(component: &str) -> Option<(String, Status)> {
Command::new(component)
.arg("-V")
.output()
.map(|o| String::from_utf8_lossy(o.stdout.as_slice()).to_string())
.map(|v| {
format!(
"{component}: {}",
v.split('\n')
.next()
.unwrap()
.strip_prefix(&format!("{component} "))
.unwrap_or_default()
)
})
.map(|desc| (desc, Status::Success))
.ok()
}
pub fn items() -> Vec<SectionItem> {
vec![
SectionItem::new().action(|| {
component_version("rustc")
.unwrap_or_else(|| {
(
format!(
"rustc: {}\nMaybe you don't have rust installed! Visit {}",
"not installed!".red(),
"https://rustup.rs/".cyan()
),
Status::Error,
)
}).into()
}),
SectionItem::new().action(|| {
component_version("cargo")
.unwrap_or_else(|| {
(
format!(
"Cargo: {}\nMaybe you don't have rust installed! Visit {}",
"not installed!".red(),
"https://rustup.rs/".cyan()
),
Status::Error,
)
}).into()
}),
SectionItem::new().action(|| {
component_version("rustup")
.unwrap_or_else(|| {
(
format!(
"rustup: {}\nIf you have rust installed some other way, we recommend uninstalling it\nthen use rustup instead. Visit {}",
"not installed!".red(),
"https://rustup.rs/".cyan()
),
Status::Warning,
)
}).into()
}),
SectionItem::new().action(|| {
Command::new("rustup")
.args(["show", "active-toolchain"])
.output()
.map(|o| String::from_utf8_lossy(o.stdout.as_slice()).to_string())
.map(|v| {
format!(
"Rust toolchain: {}",
v.split('\n')
.next()
.unwrap()
)
})
.map(|desc| (desc, Status::Success))
.ok()
.unwrap_or_else(|| {
(
format!(
"Rust toolchain: couldn't be detected!\nMaybe you don't have rustup installed? if so, Visit {}", "https://rustup.rs/".cyan()
),
Status::Warning,
)
}).into()
}),
]
}

View File

@@ -0,0 +1,359 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use super::{SectionItem, Status};
#[cfg(windows)]
use crate::error::Context;
use colored::Colorize;
#[cfg(windows)]
use serde::Deserialize;
use std::process::Command;
#[cfg(windows)]
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct VsInstanceInfo {
display_name: String,
}
#[cfg(windows)]
fn build_tools_version() -> crate::Result<Vec<String>> {
let vswhere =
tauri_bundler::bundle::vswhere_path().context("failed to find or prepare vswhere.exe")?;
// Check if there are Visual Studio installations that have the "MSVC - C++ Buildtools" and "Windows SDK" components.
// Both the Windows 10 and Windows 11 SDKs work so we need to query it twice.
let output_sdk10 = Command::new(&vswhere)
.args([
"-prerelease",
"-products",
"*",
"-requires",
"Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
"-requires",
"Microsoft.VisualStudio.Component.Windows10SDK.*",
"-format",
"json",
"-utf8",
])
.output()
.map_err(|error| crate::error::Error::CommandFailed {
command: "vswhere -prerelease -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -requires Microsoft.VisualStudio.Component.Windows10SDK.* -format json -utf8".to_string(),
error,
})?;
let output_sdk11 = Command::new(vswhere)
.args([
"-prerelease",
"-products",
"*",
"-requires",
"Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
"-requires",
"Microsoft.VisualStudio.Component.Windows11SDK.*",
"-format",
"json",
"-utf8",
])
.output()
.map_err(|error| crate::error::Error::CommandFailed {
command: "vswhere -prerelease -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -requires Microsoft.VisualStudio.Component.Windows11SDK.* -format json -utf8".to_string(),
error,
})?;
let mut instances: Vec<VsInstanceInfo> = Vec::new();
if output_sdk10.status.success() {
let stdout = String::from_utf8_lossy(&output_sdk10.stdout);
let found: Vec<VsInstanceInfo> =
serde_json::from_str(&stdout).context("failed to parse vswhere output")?;
instances.extend(found);
}
if output_sdk11.status.success() {
let stdout = String::from_utf8_lossy(&output_sdk11.stdout);
let found: Vec<VsInstanceInfo> =
serde_json::from_str(&stdout).context("failed to parse vswhere output")?;
instances.extend(found);
}
let mut instances: Vec<String> = instances
.iter()
.map(|i| i.display_name.clone())
.collect::<Vec<String>>();
instances.sort_unstable();
instances.dedup();
Ok(instances)
}
#[cfg(windows)]
fn webview2_version() -> crate::Result<Option<String>> {
let powershell_path = std::env::var("SYSTEMROOT").map_or_else(
|_| "powershell.exe".to_string(),
|p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
);
// check 64bit machine-wide installation
let output = Command::new(&powershell_path)
.args(["-NoProfile", "-Command"])
.arg("Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}' | ForEach-Object {$_.pv}")
.output()
.map_err(|error| crate::error::Error::CommandFailed {
command: "Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}' | ForEach-Object {$_.pv}".to_string(),
error,
})?;
if output.status.success() {
return Ok(Some(
String::from_utf8_lossy(&output.stdout).replace('\n', ""),
));
}
// check 32bit machine-wide installation
let output = Command::new(&powershell_path)
.args(["-NoProfile", "-Command"])
.arg("Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}' | ForEach-Object {$_.pv}")
.output()
.map_err(|error| crate::error::Error::CommandFailed {
command: "Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}' | ForEach-Object {$_.pv}".to_string(),
error,
})?;
if output.status.success() {
return Ok(Some(
String::from_utf8_lossy(&output.stdout).replace('\n', ""),
));
}
// check user-wide installation
let output = Command::new(&powershell_path)
.args(["-NoProfile", "-Command"])
.arg("Get-ItemProperty -Path 'HKCU:\\SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}' | ForEach-Object {$_.pv}")
.output()
.map_err(|error| crate::error::Error::CommandFailed {
command: "Get-ItemProperty -Path 'HKCU:\\SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}' | ForEach-Object {$_.pv}".to_string(),
error,
})?;
if output.status.success() {
return Ok(Some(
String::from_utf8_lossy(&output.stdout).replace('\n', ""),
));
}
Ok(None)
}
#[cfg(any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd"
))]
fn pkg_conf_version(package: &str) -> Option<String> {
Command::new("pkg-config")
.args([package, "--print-provides"])
.output()
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.split('=')
.nth(1)
.map(|s| s.trim().to_string())
})
.unwrap_or(None)
}
#[cfg(any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd"
))]
fn webkit2gtk_ver() -> Option<String> {
pkg_conf_version("webkit2gtk-4.1")
}
#[cfg(any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd"
))]
fn rsvg2_ver() -> Option<String> {
pkg_conf_version("librsvg-2.0")
}
#[cfg(target_os = "macos")]
fn is_xcode_command_line_tools_installed() -> bool {
Command::new("xcode-select")
.arg("-p")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(target_os = "macos")]
pub fn xcode_version() -> Option<String> {
Command::new("xcodebuild")
.arg("-version")
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.and_then(|s| {
s.split('\n')
.filter_map(|line| line.strip_prefix("Xcode "))
.next()
.map(ToString::to_string)
})
}
fn de_and_session() -> String {
#[cfg(any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd"
))]
return {
let de = std::env::var("XDG_SESSION_DESKTOP");
let session = std::env::var("XDG_SESSION_TYPE");
format!(
" ({} on {})",
de.as_deref().unwrap_or("Unknown DE"),
session.as_deref().unwrap_or("Unknown Session")
)
};
#[cfg(not(any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd"
)))]
String::new()
}
pub fn items() -> Vec<SectionItem> {
vec![
SectionItem::new().action(|| {
let os_info = os_info::get();
format!(
"OS: {} {} {} ({:?}){}",
os_info.os_type(),
os_info.version(),
os_info.architecture().unwrap_or("Unknown Architecture"),
os_info.bitness(),
de_and_session(),
).into()
}),
#[cfg(windows)]
SectionItem::new().action(|| {
let error = format!(
"Webview2: {}\nVisit {}",
"not installed!".red(),
"https://developer.microsoft.com/en-us/microsoft-edge/webview2/".cyan()
);
webview2_version()
.map(|v| {
v.map(|v| (format!("WebView2: {v}"), Status::Success))
.unwrap_or_else(|| (error.clone(), Status::Error))
})
.unwrap_or_else(|_| (error, Status::Error)).into()
}),
#[cfg(windows)]
SectionItem::new().action(|| {
let build_tools = build_tools_version().unwrap_or_default();
if build_tools.is_empty() {
(
format!(
"Couldn't detect any Visual Studio or VS Build Tools instance with MSVC and SDK components. Download from {}",
"https://aka.ms/vs/17/release/vs_BuildTools.exe".cyan()
),
Status::Error,
).into()
} else {
(
format!(
"MSVC: {}{}",
if build_tools.len() > 1 {
format!("\n {} ", "-".cyan())
} else {
"".into()
},
build_tools.join(format!("\n {} ", "-".cyan()).as_str()),
),
Status::Success,
).into()
}
}),
#[cfg(any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd"
))]
SectionItem::new().action(|| {
webkit2gtk_ver()
.map(|v| (format!("webkit2gtk-4.1: {v}"), Status::Success))
.unwrap_or_else(|| {
(
format!(
"webkit2gtk-4.1: {}\nVisit {} to learn more about tauri prerequisites",
"not installed".red(),
"https://v2.tauri.app/start/prerequisites/".cyan()
),
Status::Error,
)
}).into()
},
),
#[cfg(any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd"
))]
SectionItem::new().action(|| {
rsvg2_ver()
.map(|v| (format!("rsvg2: {v}"), Status::Success))
.unwrap_or_else(|| {
(
format!(
"rsvg2: {}\nVisit {} to learn more about tauri prerequisites",
"not installed".red(),
"https://v2.tauri.app/start/prerequisites/".cyan()
),
Status::Error,
)
}).into()
},
),
#[cfg(target_os = "macos")]
SectionItem::new().action(|| {
if is_xcode_command_line_tools_installed() {
(
"Xcode Command Line Tools: installed".into(),
Status::Success,
)
} else {
(
format!(
"Xcode Command Line Tools: {}\n Run `{}`",
"not installed!".red(),
"xcode-select --install".cyan()
),
Status::Error,
)
}.into()
},
),
#[cfg(target_os = "macos")]
SectionItem::new().action(|| {
xcode_version().map(|v| (format!("Xcode: {v}"), Status::Success)).unwrap_or_else(|| {
(format!("Xcode: {}", "not installed!".red()), Status::Error)
}).into()
}),
]
}

View File

@@ -0,0 +1,27 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use super::SectionItem;
use colored::Colorize;
pub fn items() -> Vec<SectionItem> {
vec![SectionItem::new().action(|| {
let teams = cargo_mobile2::apple::teams::find_development_teams().unwrap_or_default();
if teams.is_empty() {
"Developer Teams: None".red().to_string().into()
} else {
format!(
"Developer Teams: {}",
teams
.iter()
.map(|t| format!("{} (ID: {})", t.name, t.id))
.collect::<Vec<String>>()
.join(", ")
)
.into()
}
})]
}

View File

@@ -0,0 +1,349 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::{
Result,
error::Context,
helpers::app_paths::{resolve_frontend_dir, resolve_tauri_dir},
};
use clap::Parser;
use colored::{ColoredString, Colorize};
use dialoguer::{Confirm, theme::ColorfulTheme};
use serde::Deserialize;
use std::fmt::{self, Display, Formatter};
use tauri_utils::platform::Target;
mod app;
mod env_nodejs;
mod env_rust;
pub mod env_system;
#[cfg(target_os = "macos")]
mod ios;
mod packages_nodejs;
mod packages_rust;
pub mod plugins;
#[derive(Deserialize)]
struct JsCliVersionMetadata {
version: String,
node: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VersionMetadata {
#[serde(rename = "cli.js")]
js_cli: JsCliVersionMetadata,
}
fn version_metadata() -> Result<VersionMetadata> {
serde_json::from_str::<VersionMetadata>(include_str!("../../metadata-v2.json"))
.context("failed to parse version metadata")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum Status {
Neutral = 0,
#[default]
Success,
Warning,
Error,
}
impl Status {
fn color<S: AsRef<str>>(&self, s: S) -> ColoredString {
let s = s.as_ref();
match self {
Status::Neutral => s.normal(),
Status::Success => s.green(),
Status::Warning => s.yellow(),
Status::Error => s.red(),
}
}
}
impl Display for Status {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
match self {
Status::Neutral => "-".cyan(),
Status::Success => "✔".green(),
Status::Warning => "⚠".yellow(),
Status::Error => "✘".red(),
}
)
}
}
#[derive(Default)]
pub enum ActionResult {
Full {
description: String,
status: Status,
},
Description(String),
#[default]
None,
}
impl From<String> for ActionResult {
fn from(value: String) -> Self {
ActionResult::Description(value)
}
}
impl From<(String, Status)> for ActionResult {
fn from(value: (String, Status)) -> Self {
ActionResult::Full {
description: value.0,
status: value.1,
}
}
}
impl From<Option<String>> for ActionResult {
fn from(value: Option<String>) -> Self {
value.map(ActionResult::Description).unwrap_or_default()
}
}
impl From<Option<(String, Status)>> for ActionResult {
fn from(value: Option<(String, Status)>) -> Self {
value
.map(|v| ActionResult::Full {
description: v.0,
status: v.1,
})
.unwrap_or_default()
}
}
pub struct SectionItem {
/// If description is none, the item is skipped
description: Option<String>,
status: Status,
action: Option<Box<dyn FnMut() -> ActionResult>>,
action_if_err: Option<Box<dyn FnMut() -> ActionResult>>,
}
impl Display for SectionItem {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let desc = self
.description
.as_ref()
.map(|s| s.replace('\n', "\n "))
.unwrap_or_default();
let (first, second) = desc.split_once(':').unwrap();
write!(f, "{} {}:{}", self.status, first.bold(), second)
}
}
impl SectionItem {
fn new() -> Self {
Self {
action: None,
action_if_err: None,
description: None,
status: Status::Neutral,
}
}
fn action<F: FnMut() -> ActionResult + 'static>(mut self, action: F) -> Self {
self.action = Some(Box::new(action));
self
}
// fn action_if_err<F: FnMut() -> ActionResult + 'static>(mut self, action: F) -> Self {
// self.action_if_err = Some(Box::new(action));
// self
// }
fn description<S: AsRef<str>>(mut self, description: S) -> Self {
self.description = Some(description.as_ref().to_string());
self
}
fn run_action(&mut self) {
let mut res = ActionResult::None;
if let Some(action) = &mut self.action {
res = action();
}
self.apply_action_result(res);
}
fn run_action_if_err(&mut self) {
let mut res = ActionResult::None;
if let Some(action) = &mut self.action_if_err {
res = action();
}
self.apply_action_result(res);
}
fn apply_action_result(&mut self, result: ActionResult) {
match result {
ActionResult::Full {
description,
status,
} => {
self.description = Some(description);
self.status = status;
}
ActionResult::Description(description) => {
self.description = Some(description);
}
ActionResult::None => {}
}
}
fn run(&mut self, interactive: bool) -> Status {
self.run_action();
if self.status == Status::Error && interactive && self.action_if_err.is_some() {
if let Some(description) = &self.description {
let confirmed = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"{}\n Run the automatic fix?",
description.replace('\n', "\n ")
))
.interact()
.unwrap_or(false);
if confirmed {
self.run_action_if_err()
}
}
}
self.status
}
}
struct Section<'a> {
label: &'a str,
interactive: bool,
items: Vec<SectionItem>,
}
impl Section<'_> {
fn display(&mut self) {
let mut status = Status::Neutral;
for item in &mut self.items {
let s = item.run(self.interactive);
if s > status {
status = s;
}
}
let status_str = format!("[{status}]");
let status = status.color(status_str);
println!();
println!("{} {}", status, self.label.bold().yellow());
for item in &self.items {
if item.description.is_some() {
println!(" {item}");
}
}
}
}
#[derive(Debug, Parser)]
#[clap(
about = "Show a concise list of information about the environment, Rust, Node.js and their versions as well as a few relevant project configurations"
)]
pub struct Options {
/// Interactive mode to apply automatic fixes.
#[clap(long)]
pub interactive: bool,
}
pub fn command(options: Options) -> Result<()> {
let Options { interactive } = options;
let frontend_dir = resolve_frontend_dir();
let tauri_dir = resolve_tauri_dir();
let package_manager = frontend_dir
.as_ref()
.map(packages_nodejs::package_manager)
.unwrap_or(crate::helpers::npm::PackageManager::Npm);
let metadata = version_metadata()?;
let mut environment = Section {
label: "Environment",
interactive,
items: Vec::new(),
};
environment.items.extend(env_system::items());
environment.items.extend(env_rust::items());
let items = env_nodejs::items(&metadata);
environment.items.extend(items);
let mut packages = Section {
label: "Packages",
interactive,
items: Vec::new(),
};
packages.items.extend(packages_rust::items(
frontend_dir.as_ref(),
tauri_dir.as_deref(),
));
packages.items.extend(packages_nodejs::items(
frontend_dir.as_ref(),
package_manager,
&metadata,
));
let mut plugins = Section {
label: "Plugins",
interactive,
items: plugins::items(frontend_dir.as_ref(), tauri_dir.as_deref(), package_manager),
};
let mut app = Section {
label: "App",
interactive,
items: Vec::new(),
};
if let Some(tauri_dir) = &tauri_dir {
if let Ok(config) = crate::helpers::config::get_config(Target::current(), &[], tauri_dir) {
app.items.extend(app::items(&config, frontend_dir.as_ref()));
};
}
environment.display();
packages.display();
plugins.display();
if let (Some(frontend_dir), Some(tauri_dir)) = (&frontend_dir, &tauri_dir) {
if let Err(error) = plugins::check_mismatched_packages(frontend_dir, tauri_dir) {
println!("\n{}: {error}", "Error".bright_red().bold());
}
}
app.display();
// iOS
#[cfg(target_os = "macos")]
{
if let Some(p) = &tauri_dir {
if p.join("gen/apple").exists() {
let mut ios = Section {
label: "iOS",
interactive,
items: Vec::new(),
};
ios.items.extend(ios::items());
ios.display();
}
}
}
Ok(())
}

View File

@@ -0,0 +1,199 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use super::SectionItem;
use super::VersionMetadata;
use colored::Colorize;
use serde::Deserialize;
use std::path::PathBuf;
use crate::error::Context;
use crate::{
error::Error,
helpers::{cross_command, npm::PackageManager},
};
#[derive(Deserialize)]
struct YarnVersionInfo {
data: Vec<String>,
}
pub fn npm_latest_version(pm: &PackageManager, name: &str) -> crate::Result<Option<String>> {
match pm {
PackageManager::Yarn => {
let mut cmd = cross_command("yarn");
let output = cmd
.arg("info")
.arg(name)
.args(["version", "--json"])
.output()
.map_err(|error| Error::CommandFailed {
command: "yarn info --json".to_string(),
error,
})?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let info: YarnVersionInfo =
serde_json::from_str(&stdout).context("failed to parse yarn info")?;
Ok(Some(info.data.last().unwrap().to_string()))
} else {
Ok(None)
}
}
PackageManager::YarnBerry => {
let mut cmd = cross_command("yarn");
let output = cmd
.arg("npm")
.arg("info")
.arg(name)
.args(["--fields", "version", "--json"])
.output()
.map_err(|error| Error::CommandFailed {
command: "yarn npm info --fields version --json".to_string(),
error,
})?;
if output.status.success() {
let info: crate::PackageJson = serde_json::from_reader(std::io::Cursor::new(output.stdout))
.context("failed to parse yarn npm info")?;
Ok(info.version)
} else {
Ok(None)
}
}
// Bun and Deno don't support show command
PackageManager::Npm | PackageManager::Deno | PackageManager::Bun => {
let mut cmd = cross_command("npm");
let output = cmd
.arg("show")
.arg(name)
.arg("version")
.output()
.map_err(|error| Error::CommandFailed {
command: "npm show --version".to_string(),
error,
})?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(Some(stdout.replace('\n', "")))
} else {
Ok(None)
}
}
PackageManager::Pnpm => {
let mut cmd = cross_command("pnpm");
let output = cmd
.arg("info")
.arg(name)
.arg("version")
.output()
.map_err(|error| Error::CommandFailed {
command: "pnpm info --version".to_string(),
error,
})?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(Some(stdout.replace('\n', "")))
} else {
Ok(None)
}
}
}
}
pub fn package_manager(frontend_dir: &PathBuf) -> PackageManager {
let found = PackageManager::all_from_project(frontend_dir);
if found.is_empty() {
println!(
"{}: no lock files found, defaulting to npm",
"WARNING".yellow()
);
return PackageManager::Npm;
}
let pkg_manager = found[0];
if found.len() > 1 {
println!(
"{}: Only one package manager should be used, but found {}.\n Please remove unused package manager lock files, will use {} for now!",
"WARNING".yellow(),
found
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(" and "),
pkg_manager
);
}
pkg_manager
}
pub fn items(
frontend_dir: Option<&PathBuf>,
package_manager: PackageManager,
metadata: &VersionMetadata,
) -> Vec<SectionItem> {
let mut items = Vec::new();
if let Some(frontend_dir) = frontend_dir {
for (package, version) in [
("@tauri-apps/api", None),
("@tauri-apps/cli", Some(metadata.js_cli.version.clone())),
] {
let frontend_dir = frontend_dir.clone();
let item = nodejs_section_item(package.into(), version, frontend_dir, package_manager);
items.push(item);
}
}
items
}
pub fn nodejs_section_item(
package: String,
version: Option<String>,
frontend_dir: PathBuf,
package_manager: PackageManager,
) -> SectionItem {
SectionItem::new().action(move || {
let version = version.clone().unwrap_or_else(|| {
package_manager
.current_package_version(&package, &frontend_dir)
.unwrap_or_default()
.unwrap_or_default()
});
let latest_ver = super::packages_nodejs::npm_latest_version(&package_manager, &package)
.unwrap_or_default()
.unwrap_or_default();
if version.is_empty() {
format!("{} {}: not installed!", package, " ⱼₛ".black().on_yellow())
} else {
format!(
"{} {}: {}{}",
package,
" ⱼₛ".black().on_yellow(),
version,
if !(version.is_empty() || latest_ver.is_empty()) {
let version = semver::Version::parse(version.as_str()).unwrap();
let target_version = semver::Version::parse(latest_ver.as_str()).unwrap();
if version < target_version {
format!(" ({}, latest: {})", "outdated".yellow(), latest_ver.green())
} else {
"".into()
}
} else {
"".into()
}
)
}
.into()
})
}

View File

@@ -0,0 +1,100 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use super::{ActionResult, SectionItem};
use crate::helpers::cargo_manifest::{
CrateVersion, cargo_manifest_and_lock, crate_latest_version, crate_version,
};
use colored::Colorize;
use std::path::{Path, PathBuf};
pub fn items(frontend_dir: Option<&PathBuf>, tauri_dir: Option<&Path>) -> Vec<SectionItem> {
let mut items = Vec::new();
if tauri_dir.is_some() || frontend_dir.is_some() {
if let Some(tauri_dir) = tauri_dir {
let (manifest, lock) = cargo_manifest_and_lock(tauri_dir);
for dep in ["tauri", "tauri-build", "wry", "tao"] {
let crate_version = crate_version(tauri_dir, manifest.as_ref(), lock.as_ref(), dep);
let item = rust_section_item(dep, crate_version);
items.push(item);
}
}
}
let tauri_cli_rust_item = SectionItem::new().action(|| {
std::process::Command::new("cargo")
.arg("tauri")
.arg("-V")
.output()
.ok()
.map(|o| {
if o.status.success() {
let out = String::from_utf8_lossy(o.stdout.as_slice());
let (package, version) = out.split_once(' ').unwrap_or_default();
let version = version.strip_suffix('\n').unwrap_or(version);
let latest_version = crate_latest_version(package).unwrap_or_default();
format!(
"{package} 🦀: {version}{}",
if !(version.is_empty() || latest_version.is_empty()) {
let current_version = semver::Version::parse(version).unwrap();
let target_version = semver::Version::parse(latest_version.as_str()).unwrap();
if current_version < target_version {
format!(
" ({}, latest: {})",
"outdated".yellow(),
latest_version.green()
)
} else {
"".into()
}
} else {
"".into()
}
)
.into()
} else {
ActionResult::None
}
})
.unwrap_or_default()
});
items.push(tauri_cli_rust_item);
items
}
pub fn rust_section_item(dep: &str, crate_version: CrateVersion) -> SectionItem {
let version = crate_version
.version
.as_ref()
.and_then(|v| semver::Version::parse(v).ok());
let version_suffix = match (version, crate_latest_version(dep)) {
(Some(version), Some(target_version)) => {
let target_version = semver::Version::parse(&target_version).unwrap();
if version < target_version {
Some(format!(
" ({}, latest: {})",
"outdated".yellow(),
target_version.to_string().green()
))
} else {
None
}
}
_ => None,
};
SectionItem::new().description(format!(
"{} {}: {}{}",
dep,
"🦀",
crate_version,
version_suffix
.map(|s| format!(",{s}"))
.unwrap_or_else(|| "".into())
))
}

View File

@@ -0,0 +1,170 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::{
collections::HashMap,
iter,
path::{Path, PathBuf},
};
use crate::{
Error,
helpers::{
self,
cargo_manifest::{cargo_manifest_and_lock, crate_version},
npm::PackageManager,
},
};
use super::{SectionItem, packages_nodejs, packages_rust};
#[derive(Debug)]
pub struct InstalledPackage {
pub crate_name: String,
pub npm_name: String,
pub crate_version: semver::Version,
pub npm_version: semver::Version,
}
#[derive(Debug)]
pub struct InstalledPackages(Vec<InstalledPackage>);
impl InstalledPackages {
pub fn mismatched(&self) -> Vec<&InstalledPackage> {
self
.0
.iter()
.filter(|p| {
p.crate_version.major != p.npm_version.major || p.crate_version.minor != p.npm_version.minor
})
.collect()
}
}
pub fn installed_tauri_packages(
frontend_dir: &Path,
tauri_dir: &Path,
package_manager: PackageManager,
) -> InstalledPackages {
let known_plugins = helpers::plugins::known_plugins();
let crate_names: Vec<String> = iter::once("tauri".to_owned())
.chain(
known_plugins
.keys()
.map(|plugin_name| format!("tauri-plugin-{plugin_name}")),
)
.collect();
let npm_names: Vec<String> = iter::once("@tauri-apps/api".to_owned())
.chain(
known_plugins
.keys()
.map(|plugin_name| format!("@tauri-apps/plugin-{plugin_name}")),
)
.collect();
let (manifest, lock) = cargo_manifest_and_lock(tauri_dir);
let mut rust_plugins: HashMap<String, semver::Version> = crate_names
.iter()
.filter_map(|crate_name| {
let crate_version =
crate_version(tauri_dir, manifest.as_ref(), lock.as_ref(), crate_name).version?;
let crate_version = semver::Version::parse(&crate_version)
.inspect_err(|_| {
// On first run there's no lockfile yet so we get the version requirement from Cargo.toml.
// In our templates that's `2` which is not a valid semver version but a version requirement.
// log::error confused users so we use log::debug to still be able to see this error if needed.
log::debug!("Failed to parse version `{crate_version}` for crate `{crate_name}`");
})
.ok()?;
Some((crate_name.clone(), crate_version))
})
.collect();
let mut npm_plugins = package_manager
.current_package_versions(&npm_names, frontend_dir)
.unwrap_or_default();
let installed_plugins = crate_names
.iter()
.zip(npm_names.iter())
.filter_map(|(crate_name, npm_name)| {
let (crate_name, crate_version) = rust_plugins.remove_entry(crate_name)?;
let (npm_name, npm_version) = npm_plugins.remove_entry(npm_name)?;
Some(InstalledPackage {
npm_name,
npm_version,
crate_name,
crate_version,
})
})
.collect();
InstalledPackages(installed_plugins)
}
pub fn items(
frontend_dir: Option<&PathBuf>,
tauri_dir: Option<&Path>,
package_manager: PackageManager,
) -> Vec<SectionItem> {
let mut items = Vec::new();
if let Some(tauri_dir) = tauri_dir {
let (manifest, lock) = cargo_manifest_and_lock(tauri_dir);
for (name, metadata) in helpers::plugins::known_plugins() {
let dep = format!("tauri-plugin-{name}");
let crate_version = crate_version(tauri_dir, manifest.as_ref(), lock.as_ref(), &dep);
if !crate_version.has_version() {
continue;
}
let item = packages_rust::rust_section_item(&dep, crate_version);
items.push(item);
if metadata.rust_only {
continue;
}
let Some(frontend_dir) = frontend_dir else {
continue;
};
let package = format!("@tauri-apps/plugin-{name}");
let item =
packages_nodejs::nodejs_section_item(package, None, frontend_dir.clone(), package_manager);
items.push(item);
}
}
items
}
pub fn check_mismatched_packages(frontend_dir: &Path, tauri_path: &Path) -> crate::Result<()> {
let installed_packages = installed_tauri_packages(
frontend_dir,
tauri_path,
PackageManager::from_project(frontend_dir),
);
let mismatched_packages = installed_packages.mismatched();
if mismatched_packages.is_empty() {
return Ok(());
}
let mismatched_text = mismatched_packages
.iter()
.map(
|InstalledPackage {
crate_name,
crate_version,
npm_name,
npm_version,
}| format!("{crate_name} (v{crate_version}) : {npm_name} (v{npm_version})"),
)
.collect::<Vec<_>>()
.join("\n");
Err(Error::GenericError(format!(
"Found version mismatched Tauri packages. Make sure the NPM package and Rust crate versions are on the same major/minor releases:\n{mismatched_text}"
)))
}

View File

@@ -0,0 +1,465 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::{
VersionMetadata,
helpers::{
framework::{Framework, infer_from_package_json as infer_framework},
npm::PackageManager,
prompts, resolve_tauri_path, template,
},
};
use std::{
collections::BTreeMap,
env::current_dir,
fs::{read_to_string, remove_dir_all},
io::IsTerminal,
path::{Path, PathBuf},
};
use crate::{
Result,
error::{Context, ErrorExt},
};
use clap::Parser;
use handlebars::{Handlebars, to_json};
use include_dir::{Dir, include_dir};
const TEMPLATE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/templates/app");
const TAURI_CONF_TEMPLATE: &str = include_str!("../templates/tauri.conf.json");
#[derive(Debug, Parser)]
#[clap(about = "Initialize a Tauri project in an existing directory")]
pub struct Options {
/// Skip prompting for values
#[clap(long, env = "CI")]
ci: bool,
/// Force init to overwrite the src-tauri folder
#[clap(short, long)]
force: bool,
/// Enables logging
#[clap(short, long)]
log: bool,
/// Set target directory for init
#[clap(short, long)]
#[clap(default_value_t = current_dir().expect("failed to read cwd").display().to_string())]
directory: String,
/// Path of the Tauri project to use (relative to the cwd)
#[clap(short, long)]
tauri_path: Option<PathBuf>,
/// Name of your Tauri application
#[clap(short = 'A', long)]
app_name: Option<String>,
/// Window title of your Tauri application
#[clap(short = 'W', long)]
window_title: Option<String>,
/// Web assets location, relative to <project-dir>/src-tauri
#[clap(short = 'D', long)]
frontend_dist: Option<String>,
/// Url of your dev server
#[clap(short = 'P', long)]
dev_url: Option<String>,
/// A shell command to run before `tauri dev` kicks in.
#[clap(long)]
before_dev_command: Option<String>,
/// A shell command to run before `tauri build` kicks in.
#[clap(long)]
before_build_command: Option<String>,
}
#[derive(Default)]
struct InitDefaults {
app_name: Option<String>,
framework: Option<Framework>,
}
impl Options {
fn load(mut self) -> Result<Self> {
if !std::io::stdin().is_terminal() {
self.ci = true;
}
let package_json_path = PathBuf::from(&self.directory).join("package.json");
let init_defaults = if package_json_path.exists() {
let package_json_text =
read_to_string(&package_json_path).fs_context("failed to read", &package_json_path)?;
let package_json: crate::PackageJson =
serde_json::from_str(&package_json_text).context("failed to parse JSON")?;
let (framework, _) = infer_framework(&package_json_text);
InitDefaults {
app_name: package_json.product_name.or(package_json.name),
framework,
}
} else {
Default::default()
};
self.app_name = self.app_name.map(|s| Ok(Some(s))).unwrap_or_else(|| {
prompts::input(
"What is your app name?",
Some(
init_defaults
.app_name
.clone()
.unwrap_or_else(|| "Tauri App".to_string()),
),
self.ci,
true,
)
})?;
self.window_title = self.window_title.map(|s| Ok(Some(s))).unwrap_or_else(|| {
prompts::input(
"What should the window title be?",
Some(
init_defaults
.app_name
.clone()
.unwrap_or_else(|| "Tauri".to_string()),
),
self.ci,
true,
)
})?;
self.frontend_dist = self.frontend_dist.map(|s| Ok(Some(s))).unwrap_or_else(|| prompts::input(
r#"Where are your web assets (HTML/CSS/JS) located, relative to the "<current dir>/src-tauri/tauri.conf.json" file that will be created?"#,
init_defaults.framework.as_ref().map(|f| f.frontend_dist()),
self.ci,
false,
))?;
self.dev_url = self.dev_url.map(|s| Ok(Some(s))).unwrap_or_else(|| {
prompts::input(
"What is the url of your dev server?",
init_defaults.framework.map(|f| f.dev_url()),
self.ci,
true,
)
})?;
let detected_package_manager = PackageManager::from_project(&self.directory);
self.before_dev_command = self
.before_dev_command
.map(|s| Ok(Some(s)))
.unwrap_or_else(|| {
prompts::input(
"What command should Tauri run before `tauri dev` to start your frontend? (leave empty if not needed)",
Some(default_dev_command(detected_package_manager).into()),
self.ci,
true,
)
})?;
self.before_build_command = self
.before_build_command
.map(|s| Ok(Some(s)))
.unwrap_or_else(|| {
prompts::input(
"What command should Tauri run before `tauri build` to build your frontend? (leave empty if not needed)",
Some(default_build_command(detected_package_manager).into()),
self.ci,
true,
)
})?;
Ok(self)
}
}
fn default_dev_command(pm: PackageManager) -> &'static str {
match pm {
PackageManager::Yarn => "yarn dev",
PackageManager::YarnBerry => "yarn dev",
PackageManager::Npm => "npm run dev",
PackageManager::Pnpm => "pnpm dev",
PackageManager::Bun => "bun dev",
PackageManager::Deno => "deno task dev",
}
}
fn default_build_command(pm: PackageManager) -> &'static str {
match pm {
PackageManager::Yarn => "yarn build",
PackageManager::YarnBerry => "yarn build",
PackageManager::Npm => "npm run build",
PackageManager::Pnpm => "pnpm build",
PackageManager::Bun => "bun build",
PackageManager::Deno => "deno task build",
}
}
pub fn command(mut options: Options) -> Result<()> {
options = options.load()?;
let template_target_path = PathBuf::from(&options.directory).join("src-tauri");
let metadata = serde_json::from_str::<VersionMetadata>(include_str!("../metadata-v2.json"))
.context("failed to parse version metadata")?;
if template_target_path.exists() && !options.force {
log::warn!(
"Tauri dir ({:?}) not empty. Run `init --force` to overwrite.",
template_target_path
);
} else {
let _ = remove_dir_all(&template_target_path);
let mut handlebars = Handlebars::new();
handlebars.register_escape_fn(handlebars::no_escape);
let mut data = tauri_dependencies_data(options.tauri_path.as_deref(), &metadata);
data.insert(
"frontend_dist",
to_json(options.frontend_dist.as_deref().unwrap_or("../dist")),
);
data.insert("dev_url", to_json(options.dev_url));
data.insert(
"app_name",
to_json(options.app_name.as_deref().unwrap_or("Tauri App")),
);
data.insert(
"window_title",
to_json(options.window_title.as_deref().unwrap_or("Tauri")),
);
data.insert("before_dev_command", to_json(options.before_dev_command));
data.insert(
"before_build_command",
to_json(options.before_build_command),
);
let mut config = serde_json::from_str(
&handlebars
.render_template(TAURI_CONF_TEMPLATE, &data)
.expect("Failed to render tauri.conf.json template"),
)
.unwrap();
if option_env!("TARGET") == Some("node") {
let mut dir = current_dir().expect("failed to read cwd");
let mut count = 0;
let mut cli_node_module_path = None;
let cli_path = "node_modules/@tauri-apps/cli";
// only go up three folders max
while count <= 2 {
let test_path = dir.join(cli_path);
if test_path.exists() {
let mut node_module_path = PathBuf::from("..");
for _ in 0..count {
node_module_path.push("..");
}
node_module_path.push(cli_path);
node_module_path.push("config.schema.json");
cli_node_module_path.replace(node_module_path);
break;
}
count += 1;
match dir.parent() {
Some(parent) => {
dir = parent.to_path_buf();
}
None => break,
}
}
if let Some(cli_node_module_path) = cli_node_module_path {
let mut map = serde_json::Map::default();
map.insert(
"$schema".into(),
serde_json::Value::String(
cli_node_module_path
.display()
.to_string()
.replace('\\', "/"),
),
);
let merge_config = serde_json::Value::Object(map);
json_patch::merge(&mut config, &merge_config);
}
}
data.insert(
"tauri_config",
to_json(serde_json::to_string_pretty(&config).unwrap()),
);
template::render(&handlebars, &data, &TEMPLATE_DIR, &options.directory)
.with_context(|| "failed to render Tauri template")?;
}
Ok(())
}
/// Builds the template variables for the Tauri crate dependencies of the generated `Cargo.toml`.
///
/// `tauri_dep` and `tauri_build_dep` are always set.
///
/// When `tauri_path` is provided the dependencies point to the crates inside that directory
/// and `patch_tauri_dep`, `tauri_utils_dep` and `tauri_plugin_dep` are also set so the template
/// renders a `[patch.crates-io]` section overriding the Tauri crates pulled in by other dependencies.
/// Without a path the template never renders that section, so those variables are not needed.
fn tauri_dependencies_data(
tauri_path: Option<&Path>,
metadata: &VersionMetadata,
) -> BTreeMap<&'static str, serde_json::Value> {
let mut data = BTreeMap::new();
if let Some(tauri_path) = tauri_path {
let path_dep = |crate_dir: &str| {
to_json(format!(
"{{ path = {:?} }}",
resolve_tauri_path(tauri_path, crate_dir)
))
};
data.insert("tauri_dep", path_dep("crates/tauri"));
data.insert("tauri_build_dep", path_dep("crates/tauri-build"));
data.insert("patch_tauri_dep", to_json(true));
data.insert("tauri_utils_dep", path_dep("crates/tauri-utils"));
data.insert("tauri_plugin_dep", path_dep("crates/tauri-plugin"));
} else {
data.insert(
"tauri_dep",
to_json(format!(r#"{{ version = "{}" }}"#, metadata.tauri)),
);
data.insert(
"tauri_build_dep",
to_json(format!(r#"{{ version = "{}" }}"#, metadata.tauri_build)),
);
}
data
}
#[cfg(test)]
mod tests {
use super::*;
/// The version metadata shipped with the CLI, the same source `command` uses.
fn metadata() -> VersionMetadata {
serde_json::from_str(include_str!("../metadata-v2.json"))
.expect("failed to parse version metadata")
}
/// Renders the app `Cargo.toml` template with the same handlebars setup used by `command`.
fn render_manifest(data: &BTreeMap<&'static str, serde_json::Value>) -> toml::Table {
let template = TEMPLATE_DIR
.get_file("src-tauri/Cargo.crate-manifest")
.expect("app template is missing src-tauri/Cargo.crate-manifest")
.contents_utf8()
.expect("Cargo manifest template is not UTF-8");
let mut handlebars = Handlebars::new();
handlebars.register_escape_fn(handlebars::no_escape);
let rendered = handlebars
.render_template(template, data)
.expect("failed to render Cargo manifest template");
assert!(
!rendered.contains("{{") && !rendered.contains("}}"),
"rendered manifest contains handlebars braces:\n{rendered}"
);
toml::from_str(&rendered)
.unwrap_or_else(|e| panic!("rendered manifest is not valid TOML: {e}\n{rendered}"))
}
fn dependency<'a>(manifest: &'a toml::Table, section: &str, name: &str) -> &'a toml::Table {
manifest
.get(section)
.and_then(|s| s.get(name))
.and_then(|d| d.as_table())
.unwrap_or_else(|| panic!("[{section}] is missing the `{name}` table"))
}
fn assert_path_dependency(dep: &toml::Table, tauri_path: &Path, crate_dir: &str) {
assert!(
!dep.contains_key("version"),
"path dependency must not carry a version: {dep:?}"
);
let path = dep
.get("path")
.and_then(|p| p.as_str())
.unwrap_or_else(|| panic!("dependency is missing a string `path`: {dep:?}"));
assert_eq!(Path::new(path), resolve_tauri_path(tauri_path, crate_dir));
}
fn assert_manifest_uses_path(manifest: &toml::Table, tauri_path: &Path) {
assert_path_dependency(
dependency(manifest, "dependencies", "tauri"),
tauri_path,
"crates/tauri",
);
assert_path_dependency(
dependency(manifest, "build-dependencies", "tauri-build"),
tauri_path,
"crates/tauri-build",
);
let patch = manifest
.get("patch")
.and_then(|p| p.get("crates-io"))
.and_then(|p| p.as_table())
.expect("[patch.crates-io] must be rendered for path dependencies");
assert_eq!(
patch.len(),
3,
"unexpected [patch.crates-io] entries: {patch:?}"
);
for (name, crate_dir) in [
("tauri", "crates/tauri"),
("tauri-utils", "crates/tauri-utils"),
("tauri-plugin", "crates/tauri-plugin"),
] {
let dep = patch
.get(name)
.and_then(|d| d.as_table())
.unwrap_or_else(|| panic!("[patch.crates-io] is missing `{name}`"));
assert_path_dependency(dep, tauri_path, crate_dir);
}
}
#[test]
fn version_dependencies() {
let metadata = metadata();
let data = tauri_dependencies_data(None, &metadata);
assert!(
!data.contains_key("patch_tauri_dep"),
"patch_tauri_dep must only be set for path dependencies"
);
let manifest = render_manifest(&data);
let tauri = dependency(&manifest, "dependencies", "tauri");
assert_eq!(
tauri.get("version").and_then(|v| v.as_str()),
Some(metadata.tauri.as_str())
);
assert!(!tauri.contains_key("path"));
let tauri_build = dependency(&manifest, "build-dependencies", "tauri-build");
assert_eq!(
tauri_build.get("version").and_then(|v| v.as_str()),
Some(metadata.tauri_build.as_str())
);
assert!(!tauri_build.contains_key("path"));
assert!(
!manifest.contains_key("patch"),
"[patch.crates-io] must not be rendered for version dependencies"
);
}
#[test]
fn relative_path_dependencies() {
let tauri_path = Path::new("tauri-src");
let manifest = render_manifest(&tauri_dependencies_data(Some(tauri_path), &metadata()));
assert_manifest_uses_path(&manifest, tauri_path);
}
#[test]
fn absolute_path_dependencies() {
// absolute paths are written as-is; on Windows this covers backslash escaping
let tauri_path = std::env::current_dir().unwrap().join("tauri-src");
assert!(tauri_path.is_absolute());
let manifest = render_manifest(&tauri_dependencies_data(Some(&tauri_path), &metadata()));
assert_manifest_uses_path(&manifest, &tauri_path);
}
}

View File

@@ -0,0 +1,57 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::path::Path;
use crate::Result;
use clap::{Parser, Subcommand};
use crate::interface::{AppInterface, AppSettings};
#[derive(Debug, Parser)]
#[clap(about = "Inspect values used by Tauri")]
pub struct Cli {
#[clap(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Print the default Upgrade Code used by MSI installer derived from productName.
WixUpgradeCode,
}
pub fn command(cli: Cli) -> Result<()> {
let dirs = crate::helpers::app_paths::resolve_dirs();
match cli.command {
Commands::WixUpgradeCode => wix_upgrade_code(dirs.tauri),
}
}
// NOTE: if this is ever changed, make sure to also update Wix upgrade code generation in tauri-bundler
fn wix_upgrade_code(tauri_dir: &Path) -> Result<()> {
let target = tauri_utils::platform::Target::Windows;
let config = crate::helpers::config::get_config(target, &[], tauri_dir)?;
let interface = AppInterface::new(&config, None, tauri_dir)?;
let product_name = interface.app_settings().get_package_settings().product_name;
let upgrade_code = uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_DNS,
format!("{product_name}.exe.app.x64").as_bytes(),
);
log::info!("Default WiX Upgrade Code, derived from {product_name}: {upgrade_code}");
if let Some(code) = config
.bundle
.windows
.wix
.as_ref()
.and_then(|wix| wix.upgrade_code)
{
log::info!("Application Upgrade Code override: {code}");
}
Ok(())
}

View File

@@ -0,0 +1,97 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
pub mod rust;
use std::{
path::{Path, PathBuf},
process::ExitStatus,
};
use crate::{error::Context, helpers::config::Config};
use tauri_bundler::bundle::{PackageType, Settings, SettingsBuilder};
pub use rust::{MobileOptions, Options, Rust as AppInterface, WatcherOptions};
pub trait DevProcess {
fn kill(&self) -> std::io::Result<()>;
#[allow(unused)]
fn wait(&self) -> std::io::Result<ExitStatus>;
#[allow(unused)]
fn manually_killed_process(&self) -> bool;
}
pub trait AppSettings {
fn get_package_settings(&self) -> tauri_bundler::PackageSettings;
fn get_bundle_settings(
&self,
options: &Options,
config: &Config,
features: &[String],
tauri_dir: &Path,
) -> crate::Result<tauri_bundler::BundleSettings>;
fn app_binary_path(&self, options: &Options, tauri_dir: &Path) -> crate::Result<PathBuf>;
fn get_binaries(
&self,
options: &Options,
tauri_dir: &Path,
) -> crate::Result<Vec<tauri_bundler::BundleBinary>>;
fn app_name(&self) -> Option<String>;
fn lib_name(&self) -> Option<String>;
fn get_bundler_settings(
&self,
options: Options,
config: &Config,
out_dir: &Path,
package_types: Vec<PackageType>,
tauri_dir: &Path,
) -> crate::Result<Settings> {
let no_default_features = options.args.contains(&"--no-default-features".into());
let mut enabled_features = options.features.clone();
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().context("failed to get target triple")?
};
let mut bins = self.get_binaries(&options, tauri_dir)?;
if let Some(main_binary_name) = &config.main_binary_name {
let main = bins.iter_mut().find(|b| b.main()).context("no main bin?")?;
main.set_name(main_binary_name.to_owned());
}
let mut settings_builder = SettingsBuilder::new()
.package_settings(self.get_package_settings())
.bundle_settings(self.get_bundle_settings(&options, config, &enabled_features, tauri_dir)?)
.binaries(bins)
.project_out_directory(out_dir)
.target(target)
.package_types(package_types);
if config.bundle.use_local_tools_dir {
settings_builder = settings_builder.local_tools_directory(
rust::get_cargo_metadata(tauri_dir)
.context("failed to get cargo metadata")?
.target_directory,
)
}
settings_builder.build().map_err(Into::into)
}
}
#[derive(Debug)]
pub enum ExitReason {
/// Killed manually.
TriggeredKill,
/// App compilation failed.
CompilationFailed,
/// Regular exit.
NormalExit,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use serde::Deserialize;
use std::{
fs,
path::{Path, PathBuf},
};
use tauri_utils::display_path;
use crate::{
Result,
error::{Context, ErrorExt},
};
struct PathAncestors<'a> {
current: Option<&'a Path>,
}
impl<'a> PathAncestors<'a> {
fn new(path: &'a Path) -> PathAncestors<'a> {
PathAncestors {
current: Some(path),
}
}
}
impl<'a> Iterator for PathAncestors<'a> {
type Item = &'a Path;
fn next(&mut self) -> Option<&'a Path> {
if let Some(path) = self.current {
self.current = path.parent();
Some(path)
} else {
None
}
}
}
#[derive(Default, Deserialize)]
pub struct BuildConfig {
target: Option<String>,
}
#[derive(Deserialize)]
pub struct ConfigSchema {
build: Option<BuildConfig>,
}
#[derive(Default)]
pub struct Config {
build: BuildConfig,
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let mut config = Self::default();
let get_config = |path: PathBuf| -> Result<ConfigSchema> {
let contents =
fs::read_to_string(&path).fs_context("failed to read configuration file", path.clone())?;
toml::from_str(&contents).context(format!(
"could not parse TOML configuration in `{}`",
display_path(&path)
))
};
for current in PathAncestors::new(path) {
if let Some(path) = get_file_path(&current.join(".cargo"), "config", true)? {
let toml = get_config(path)?;
if let Some(target) = toml.build.and_then(|b| b.target) {
config.build.target = Some(target);
break;
}
}
}
if config.build.target.is_none() {
if let Ok(cargo_home) = std::env::var("CARGO_HOME") {
if let Some(path) = get_file_path(&PathBuf::from(cargo_home), "config", true)? {
let toml = get_config(path)?;
if let Some(target) = toml.build.and_then(|b| b.target) {
config.build.target = Some(target);
}
}
}
}
Ok(config)
}
pub fn build(&self) -> &BuildConfig {
&self.build
}
}
impl BuildConfig {
pub fn target(&self) -> Option<&str> {
self.target.as_deref()
}
}
/// The purpose of this function is to aid in the transition to using
/// .toml extensions on Cargo's config files, which were historically not used.
/// Both 'config.toml' and 'credentials.toml' should be valid with or without extension.
/// When both exist, we want to prefer the one without an extension for
/// backwards compatibility, but warn the user appropriately.
fn get_file_path(
dir: &Path,
filename_without_extension: &str,
warn: bool,
) -> Result<Option<PathBuf>> {
let possible = dir.join(filename_without_extension);
let possible_with_extension = dir.join(format!("{filename_without_extension}.toml"));
if possible.exists() {
if warn && possible_with_extension.exists() {
// We don't want to print a warning if the version
// without the extension is just a symlink to the version
// WITH an extension, which people may want to do to
// support multiple Cargo versions at once and not
// get a warning.
let skip_warning = if let Ok(target_path) = fs::read_link(&possible) {
target_path == possible_with_extension
} else {
false
};
if !skip_warning {
log::warn!(
"Both `{}` and `{}` exist. Using `{}`",
display_path(&possible),
display_path(&possible_with_extension),
display_path(&possible)
);
}
}
Ok(Some(possible))
} else if possible_with_extension.exists() {
Ok(Some(possible_with_extension))
} else {
Ok(None)
}
}

View File

@@ -0,0 +1,462 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use super::{AppSettings, DevProcess, ExitReason, Options, RustAppSettings, RustupTarget};
use crate::{
CommandExt, Error,
error::{Context, ErrorExt},
};
use shared_child::SharedChild;
use std::{
fs,
io::{BufReader, ErrorKind, Write},
path::{Path, PathBuf},
process::{Command, ExitStatus, Stdio},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
};
use tauri_utils::platform::Target as TargetPlatform;
pub struct DevChild {
manually_killed_app: Arc<AtomicBool>,
dev_child: Arc<SharedChild>,
}
impl DevProcess for DevChild {
fn kill(&self) -> std::io::Result<()> {
let pid = self.dev_child.id();
#[cfg(windows)]
{
// `/T` terminates the whole process tree, `/F` forces it
let pid = pid.to_string();
let _ = Command::new("taskkill")
.args(["/T", "/F", "/PID", pid.as_str()])
.status();
}
#[cfg(not(windows))]
{
// collect the whole tree first, then kill the root before its descendants
// so it cannot respawn them in the meantime
const KILL_TREE: &str = r#"
descendants() {
for child in $(pgrep -P "$1" 2>/dev/null); do
echo "$child"
descendants "$child"
done
}
tree=$(descendants "$1")
kill -9 "$1" 2>/dev/null
for p in $tree; do
kill -9 "$p" 2>/dev/null
done
true
"#;
let pid = pid.to_string();
let _ = Command::new("sh")
.args(["-c", KILL_TREE, "sh", pid.as_str()])
.status();
}
self.dev_child.kill()?;
self.manually_killed_app.store(true, Ordering::SeqCst);
Ok(())
}
fn wait(&self) -> std::io::Result<ExitStatus> {
self.dev_child.wait()
}
fn manually_killed_process(&self) -> bool {
self.manually_killed_app.load(Ordering::SeqCst)
}
}
pub fn run_dev<F: Fn(Option<i32>, ExitReason) + Send + Sync + 'static>(
options: Options,
run_args: &[String],
available_targets: &mut Option<Vec<RustupTarget>>,
config_features: Vec<String>,
on_exit: F,
) -> crate::Result<DevChild> {
let mut dev_cmd = cargo_command(true, options, available_targets, config_features)?;
let runner = dev_cmd.get_program().to_string_lossy().into_owned();
dev_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");
dev_cmd.arg("--color");
dev_cmd.arg("always");
dev_cmd.stdout(os_pipe::dup_stdout().unwrap());
dev_cmd.stderr(Stdio::piped());
dev_cmd.arg("--");
dev_cmd.args(run_args);
let manually_killed_app = Arc::new(AtomicBool::default());
let manually_killed_app_ = manually_killed_app.clone();
log::info!(action = "Running"; "DevCommand (`{} {}`)", dev_cmd.get_program().to_string_lossy(), dev_cmd.get_args().map(|arg| arg.to_string_lossy()).fold(String::new(), |acc, arg| format!("{acc} {arg}")));
let dev_child = match SharedChild::spawn(&mut dev_cmd) {
Ok(c) => Ok(c),
Err(e) if e.kind() == ErrorKind::NotFound => crate::error::bail!(
"`{runner}` command not found.{}",
if runner == "cargo" {
" Please follow the Tauri setup guide: https://v2.tauri.app/start/prerequisites/"
} else {
""
}
),
Err(e) => Err(Error::CommandFailed {
command: runner,
error: e,
}),
}?;
let dev_child = Arc::new(dev_child);
let dev_child_stderr = dev_child.take_stderr().unwrap();
let mut stderr = BufReader::new(dev_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();
if let Ok(0) = tauri_utils::io::read_line(&mut stderr, &mut buf) {
break;
}
let _ = io_stderr.write_all(&buf);
lines.push(String::from_utf8_lossy(&buf).into_owned());
}
});
let dev_child_ = dev_child.clone();
std::thread::spawn(move || {
let status = dev_child_.wait().expect("failed to build app");
if status.success() {
on_exit(status.code(), ExitReason::NormalExit);
} 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.code(),
if status.code() == Some(101) && is_cargo_compile_error {
ExitReason::CompilationFailed
} else if manually_killed_app_.load(Ordering::SeqCst) {
ExitReason::TriggeredKill
} else {
ExitReason::NormalExit
},
);
}
});
Ok(DevChild {
manually_killed_app,
dev_child,
})
}
pub fn build(
options: Options,
app_settings: &RustAppSettings,
available_targets: &mut Option<Vec<RustupTarget>>,
config_features: Vec<String>,
main_binary_name: Option<&str>,
tauri_dir: &Path,
) -> crate::Result<PathBuf> {
let out_dir = app_settings.out_dir(&options, tauri_dir)?;
let bin_path = app_settings.app_binary_path(&options, tauri_dir)?;
if options.target == Some("universal-apple-darwin".into()) {
std::fs::create_dir_all(&out_dir)
.fs_context("failed to create project out directory", out_dir.clone())?;
let bin_name = bin_path.file_stem().unwrap();
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 = app_settings
.out_dir(&options, tauri_dir)
.with_context(|| format!("failed to get {triple} out dir"))?;
build_production_app(options, available_targets, config_features.clone())
.with_context(|| format!("failed to build {triple} binary"))?;
lipo_cmd.arg(triple_out_dir.join(bin_name));
}
let lipo_status = lipo_cmd.output_ok()?.status;
if !lipo_status.success() {
crate::error::bail!(
"Result of `lipo` command was unsuccessful: {lipo_status}. (Is `lipo` installed?)"
);
}
} else {
build_production_app(options, available_targets, config_features)
.with_context(|| "failed to build app")?;
}
rename_app(bin_path, main_binary_name, &app_settings.target_platform)
}
fn build_production_app(
options: Options,
available_targets: &mut Option<Vec<RustupTarget>>,
config_features: Vec<String>,
) -> crate::Result<()> {
let mut build_cmd = cargo_command(false, options, available_targets, config_features)?;
let runner = build_cmd.get_program().to_string_lossy().into_owned();
match build_cmd.piped() {
Ok(status) if status.success() => Ok(()),
Ok(_) => crate::error::bail!("failed to build app"),
Err(e) if e.kind() == ErrorKind::NotFound => crate::error::bail!(
"`{}` command not found.{}",
runner,
if runner == "cargo" {
" Please follow the Tauri setup guide: https://v2.tauri.app/start/prerequisites/"
} else {
""
}
),
Err(e) => Err(Error::CommandFailed {
command: runner,
error: e,
}),
}
}
fn cargo_command(
dev: bool,
options: Options,
available_targets: &mut Option<Vec<RustupTarget>>,
config_features: Vec<String>,
) -> crate::Result<Command> {
let runner_config = options.runner.unwrap_or_else(|| "cargo".into());
let mut build_cmd = Command::new(runner_config.cmd());
build_cmd.arg(if dev { "run" } else { "build" });
// Set working directory if specified
if let Some(cwd) = runner_config.cwd() {
build_cmd.current_dir(cwd);
}
// Add runner-specific arguments first
if let Some(runner_args) = runner_config.args() {
build_cmd.args(runner_args);
}
if let Some(target) = &options.target {
if available_targets.is_none() {
*available_targets = fetch_available_targets();
}
validate_target(available_targets, target)?;
}
build_cmd.args(&options.args);
let mut features = config_features;
features.extend(options.features);
if !features.is_empty() {
build_cmd.arg("--features");
build_cmd.arg(features.join(","));
}
if !options.debug && !options.args.contains(&"--profile".to_string()) {
build_cmd.arg("--release");
}
if let Some(target) = options.target {
build_cmd.arg("--target");
build_cmd.arg(target);
}
Ok(build_cmd)
}
fn fetch_available_targets() -> Option<Vec<RustupTarget>> {
if let Ok(output) = Command::new("rustup").args(["target", "list"]).output() {
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
Some(
stdout
.split('\n')
.map(|t| {
let mut s = t.split(' ');
let name = s.next().unwrap().to_string();
let installed = s.next().map(|v| v == "(installed)").unwrap_or_default();
RustupTarget { name, installed }
})
.filter(|t| !t.name.is_empty())
.collect(),
)
} else {
None
}
}
fn validate_target(
available_targets: &Option<Vec<RustupTarget>>,
target: &str,
) -> crate::Result<()> {
if let Some(available_targets) = available_targets {
if let Some(target) = available_targets.iter().find(|t| t.name == target) {
if !target.installed {
crate::error::bail!(
"Target {target} is not installed (installed targets: {installed}). Please run `rustup target add {target}`.",
target = target.name,
installed = available_targets
.iter()
.filter(|t| t.installed)
.map(|t| t.name.as_str())
.collect::<Vec<&str>>()
.join(", ")
);
}
}
if !available_targets.iter().any(|t| t.name == target) {
crate::error::bail!(
"Target {target} does not exist. Please run `rustup target list` to see the available targets.",
target = target
);
}
}
Ok(())
}
fn rename_app(
bin_path: PathBuf,
main_binary_name: Option<&str>,
target_platform: &TargetPlatform,
) -> crate::Result<PathBuf> {
if let Some(main_binary_name) = main_binary_name {
let extension = if matches!(target_platform, TargetPlatform::Windows) {
".exe"
} else {
""
};
let new_path = bin_path.with_file_name(format!("{main_binary_name}{extension}"));
fs::rename(&bin_path, &new_path).fs_context("failed to rename app binary", bin_path)?;
Ok(new_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 windows_sys::{
Win32::{
Foundation::{CloseHandle, GENERIC_READ, GENERIC_WRITE, INVALID_HANDLE_VALUE},
Storage::FileSystem::{CreateFileA, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING},
System::Console::{
CONSOLE_SCREEN_BUFFER_INFO, GetConsoleScreenBufferInfo, GetStdHandle, STD_ERROR_HANDLE,
},
},
core::PCSTR,
};
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(
c"CONOUT$".as_ptr() as PCSTR,
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
ptr::null_mut(),
OPEN_EXISTING,
0,
std::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
}
}
}

View File

@@ -0,0 +1,39 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::{
Result,
error::{Error, ErrorExt},
};
use std::{fs::read_dir, path::PathBuf, process::Command};
pub fn installed_targets() -> Result<Vec<String>> {
let output = Command::new("rustc")
.args(["--print", "sysroot"])
.output()
.map_err(|error| Error::CommandFailed {
command: "rustc --print sysroot".to_string(),
error,
})?;
let sysroot_path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim().to_string());
let mut targets = Vec::new();
for entry in read_dir(sysroot_path.join("lib").join("rustlib"))
.fs_context(
"failed to read Rust sysroot",
sysroot_path.join("lib").join("rustlib"),
)?
.flatten()
{
if entry.file_type().map(|t| t.is_dir()).unwrap_or_default() {
let name = entry.file_name();
if name != "etc" && name != "src" {
targets.push(name.to_string_lossy().into_owned());
}
}
}
Ok(targets)
}

View File

@@ -0,0 +1,507 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::{
error::{Context, ErrorExt},
helpers::config::{Config, PatternKind},
};
use itertools::Itertools;
use toml_edit::{Array, DocumentMut, InlineTable, Item, TableLike, Value};
use std::{
collections::{HashMap, HashSet},
path::Path,
};
#[derive(Default)]
pub struct Manifest {
pub inner: DocumentMut,
pub tauri_features: HashSet<String>,
}
impl Manifest {
pub fn features(&self) -> HashMap<String, Vec<String>> {
let mut f = HashMap::new();
if let Some(features) = self
.inner
.as_table()
.get("features")
.and_then(|f| f.as_table())
{
for (feature, enabled_features) in features.into_iter() {
if let Item::Value(Value::Array(enabled_features)) = enabled_features {
let mut enabled = Vec::new();
for value in enabled_features {
if let Value::String(s) = value {
enabled.push(s.value().clone());
}
}
f.insert(feature.to_string(), enabled);
}
}
}
f
}
pub fn all_enabled_features(&self, enabled_features: &[String]) -> Vec<String> {
let mut all_enabled_features: Vec<String> = self
.tauri_features
.iter()
.map(|f| format!("tauri/{f}"))
.collect();
let manifest_features = self.features();
for f in enabled_features {
all_enabled_features.extend(get_enabled_features(&manifest_features, f));
}
all_enabled_features
}
}
fn get_enabled_features(list: &HashMap<String, Vec<String>>, feature: &str) -> Vec<String> {
let mut f = Vec::new();
if let Some(enabled_features) = list.get(feature) {
for enabled in enabled_features {
if list.contains_key(enabled) {
f.extend(get_enabled_features(list, enabled));
} else {
f.push(enabled.clone());
}
}
}
f
}
pub fn read_manifest(manifest_path: &Path) -> crate::Result<(DocumentMut, String)> {
let manifest_str = std::fs::read_to_string(manifest_path)
.fs_context("failed to read Cargo.toml", manifest_path.to_path_buf())?;
let manifest: DocumentMut = manifest_str
.parse::<DocumentMut>()
.context("failed to parse Cargo.toml")?;
Ok((manifest, manifest_str))
}
pub fn serialize_manifest(manifest: &DocumentMut) -> String {
manifest
.to_string()
// apply some formatting fixes
.replace(r#"" ,features =["#, r#"", features = ["#)
.replace(r#"" , features"#, r#"", features"#)
.replace("]}", "] }")
.replace("={", "= {")
.replace("=[", "= [")
.replace(r#"",""#, r#"", ""#)
}
pub fn toml_array(features: &HashSet<String>) -> Array {
let mut f = Array::default();
let mut features: Vec<String> = features.iter().map(|f| f.to_string()).collect();
features.sort();
for feature in features {
f.push(feature.as_str());
}
f
}
fn find_dependency<'a>(
manifest: &'a mut DocumentMut,
name: &'a str,
kind: DependencyKind,
) -> Vec<&'a mut Item> {
let table = match kind {
DependencyKind::Build => "build-dependencies",
DependencyKind::Normal => "dependencies",
};
let m = manifest.as_table_mut();
for (k, v) in m.iter_mut() {
if let Some(t) = v.as_table_mut() {
if k == table {
if let Some(item) = t.get_mut(name) {
return vec![item];
}
} else if k == "target" {
let mut matching_deps = Vec::new();
for (_, target_value) in t.iter_mut() {
if let Some(target_table) = target_value.as_table_mut() {
if let Some(deps) = target_table.get_mut(table) {
if let Some(item) = deps.as_table_mut().and_then(|t| t.get_mut(name)) {
matching_deps.push(item);
}
}
}
}
return matching_deps;
}
}
}
Vec::new()
}
fn write_features<F: Fn(&str) -> bool>(
dependency_name: &str,
item: &mut Item,
is_managed_feature: F,
features: &mut HashSet<String>,
) -> crate::Result<bool> {
if let Some(dep) = item.as_table_mut() {
inject_features_table(dep, is_managed_feature, features);
Ok(true)
} else if let Some(dep) = item.as_value_mut() {
match dep {
Value::InlineTable(table) => {
inject_features_table(table, is_managed_feature, features);
}
Value::String(version) => {
let mut def = InlineTable::default();
def.get_or_insert("version", version.to_string().replace(['\"', ' '], ""));
def.get_or_insert("features", Value::Array(toml_array(features)));
*dep = Value::InlineTable(def);
}
_ => {
crate::error::bail!(
"Unsupported {} dependency format on Cargo.toml",
dependency_name
);
}
}
Ok(true)
} else {
Ok(false)
}
}
#[derive(Debug, Clone, Copy)]
enum DependencyKind {
Build,
Normal,
}
#[derive(Debug)]
struct DependencyAllowlist {
name: String,
kind: DependencyKind,
all_cli_managed_features: Vec<&'static str>,
features: HashSet<String>,
}
fn inject_features_table<D: TableLike, F: Fn(&str) -> bool>(
dep: &mut D,
is_managed_feature: F,
features: &mut HashSet<String>,
) {
let manifest_features = dep.entry("features").or_insert(Item::None);
if let Item::Value(Value::Array(f)) = &manifest_features {
for feat in f.iter() {
if let Value::String(feature) = feat {
if !is_managed_feature(feature.value().as_str()) {
features.insert(feature.value().to_string());
}
}
}
}
if let Some(features_array) = manifest_features.as_array_mut() {
// add features that aren't in the manifest
for feature in features.iter() {
if !features_array.iter().any(|f| f.as_str() == Some(feature)) {
features_array.insert(0, feature.as_str());
}
}
// remove features that shouldn't be in the manifest anymore
let mut i = features_array.len();
while i != 0 {
let index = i - 1;
if let Some(f) = features_array.get(index).and_then(|f| f.as_str()) {
if !features.contains(f) {
features_array.remove(index);
}
}
i -= 1;
}
} else {
*manifest_features = Item::Value(Value::Array(toml_array(features)));
}
}
fn inject_features(
manifest: &mut DocumentMut,
dependencies: &mut Vec<DependencyAllowlist>,
) -> crate::Result<bool> {
let mut persist = false;
for dependency in dependencies {
let name = dependency.name.clone();
let items = find_dependency(manifest, &dependency.name, dependency.kind);
for item in items {
// do not rewrite if dependency uses workspace inheritance
if item
.get("workspace")
.and_then(|v| v.as_bool())
.unwrap_or_default()
{
log::info!(
"`{name}` dependency has workspace inheritance enabled. The features array won't be automatically rewritten. Expected features: [{}]",
dependency.features.iter().join(", ")
);
} else {
let all_cli_managed_features = dependency.all_cli_managed_features.clone();
let is_managed_feature: Box<dyn Fn(&str) -> bool> =
Box::new(move |feature| all_cli_managed_features.contains(&feature));
let should_write =
write_features(&name, item, is_managed_feature, &mut dependency.features)?;
if !persist {
persist = should_write;
}
}
}
}
Ok(persist)
}
pub fn rewrite_manifest(config: &Config, tauri_dir: &Path) -> crate::Result<(Manifest, bool)> {
let manifest_path = tauri_dir.join("Cargo.toml");
let (mut manifest, original_manifest_str) = read_manifest(&manifest_path)?;
let mut dependencies = Vec::new();
// tauri-build
let mut tauri_build_features = HashSet::new();
if let PatternKind::Isolation { .. } = config.app.security.pattern {
tauri_build_features.insert("isolation".to_string());
}
dependencies.push(DependencyAllowlist {
name: "tauri-build".into(),
kind: DependencyKind::Build,
all_cli_managed_features: vec!["isolation"],
features: tauri_build_features,
});
// tauri
let tauri_features = HashSet::from_iter(config.app.features().into_iter().map(|f| f.to_string()));
dependencies.push(DependencyAllowlist {
name: "tauri".into(),
kind: DependencyKind::Normal,
all_cli_managed_features: crate::helpers::config::AppConfig::all_features()
.into_iter()
.filter(|f| f != &"tray-icon")
.collect(),
features: tauri_features,
});
let persist = inject_features(&mut manifest, &mut dependencies)?;
let tauri_features = dependencies
.into_iter()
.find(|d| d.name == "tauri")
.unwrap()
.features;
let new_manifest_str = serialize_manifest(&manifest);
if persist && original_manifest_str.replace("\r\n", "\n") != new_manifest_str {
std::fs::write(&manifest_path, new_manifest_str)
.fs_context("failed to rewrite Cargo manifest", &manifest_path)?;
Ok((
Manifest {
inner: manifest,
tauri_features,
},
true,
))
} else {
Ok((
Manifest {
inner: manifest,
tauri_features,
},
false,
))
}
}
#[cfg(test)]
mod tests {
use super::{DependencyAllowlist, DependencyKind};
use std::collections::{HashMap, HashSet};
fn inject_features(toml: &str, mut dependencies: Vec<DependencyAllowlist>) {
let mut manifest = toml
.parse::<toml_edit::DocumentMut>()
.expect("invalid toml");
let mut expected = HashMap::new();
for dep in &dependencies {
let mut features = dep.features.clone();
for item in super::find_dependency(&mut manifest, &dep.name, dep.kind) {
let item_table = if let Some(table) = item.as_table() {
Some(table.clone())
} else if let Some(toml_edit::Value::InlineTable(table)) = item.as_value() {
Some(table.clone().into_table())
} else {
None
};
if let Some(f) = item_table.and_then(|t| t.get("features")?.as_array().cloned()) {
for feature in f.iter() {
let feature = feature.as_str().expect("feature is not a string");
if !dep.all_cli_managed_features.contains(&feature) {
features.insert(feature.into());
}
}
}
}
expected.insert(dep.name.clone(), features);
}
super::inject_features(&mut manifest, &mut dependencies).expect("failed to migrate manifest");
for dep in dependencies {
let expected_features = expected.get(&dep.name).unwrap();
for item in super::find_dependency(&mut manifest, &dep.name, dep.kind) {
let item_table = if let Some(table) = item.as_table() {
table.clone()
} else if let Some(toml_edit::Value::InlineTable(table)) = item.as_value() {
table.clone().into_table()
} else {
panic!("unexpected TOML item kind for {}", dep.name);
};
let features_array = item_table
.get("features")
.expect("missing features")
.as_array()
.expect("features must be an array")
.clone();
let mut features = Vec::new();
for feature in features_array.iter() {
let feature = feature.as_str().expect("feature must be a string");
features.push(feature);
}
for expected in expected_features {
assert!(
features.contains(&expected.as_str()),
"feature {expected} should have been injected"
);
}
}
}
}
fn tauri_dependency(features: HashSet<String>) -> DependencyAllowlist {
DependencyAllowlist {
name: "tauri".into(),
kind: DependencyKind::Normal,
all_cli_managed_features: vec!["isolation"],
features,
}
}
fn tauri_build_dependency(features: HashSet<String>) -> DependencyAllowlist {
DependencyAllowlist {
name: "tauri-build".into(),
kind: DependencyKind::Build,
all_cli_managed_features: crate::helpers::config::AppConfig::all_features(),
features,
}
}
#[test]
fn inject_features_table() {
inject_features(
r#"
[dependencies]
tauri = { version = "1", features = ["dummy"] }
[build-dependencies]
tauri-build = { version = "1" }
"#,
vec![
tauri_dependency(HashSet::from_iter(
crate::helpers::config::AppConfig::all_features()
.iter()
.map(|f| f.to_string()),
)),
tauri_build_dependency(HashSet::from_iter(vec!["isolation".into()])),
],
);
}
#[test]
fn inject_features_target() {
inject_features(
r#"
[target."cfg(windows)".dependencies]
tauri = { version = "1", features = ["dummy"] }
[target."cfg(target_os = \"macos\")".build-dependencies]
tauri-build = { version = "1" }
[target."cfg(target_os = \"linux\")".dependencies]
tauri = { version = "1", features = ["isolation"] }
[target."cfg(windows)".build-dependencies]
tauri-build = { version = "1" }
"#,
vec![
tauri_dependency(Default::default()),
tauri_build_dependency(HashSet::from_iter(vec!["isolation".into()])),
],
);
}
#[test]
fn inject_features_inline_table() {
inject_features(
r#"
[dependencies.tauri]
version = "1"
features = ["test"]
[build-dependencies.tauri-build]
version = "1"
features = ["config-toml", "codegen", "isolation"]
"#,
vec![
tauri_dependency(HashSet::from_iter(vec![
"isolation".into(),
"native-tls-vendored".into(),
])),
tauri_build_dependency(HashSet::from_iter(vec!["isolation".into()])),
],
);
}
#[test]
fn inject_features_string() {
inject_features(
r#"
[dependencies]
tauri = "1"
[build-dependencies]
tauri-build = "1"
"#,
vec![
tauri_dependency(HashSet::from_iter(vec![
"isolation".into(),
"native-tls-vendored".into(),
])),
tauri_build_dependency(HashSet::from_iter(vec!["isolation".into()])),
],
);
}
}

444
crates/tauri-cli/src/lib.rs Normal file
View File

@@ -0,0 +1,444 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! This Rust executable provides the full interface to all of the required activities for which the CLI is required. It will run on macOS, Windows, and Linux.
#![doc(
html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/.github/icon.png",
html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/.github/icon.png"
)]
#![cfg(any(target_os = "macos", target_os = "linux", windows))]
mod acl;
mod add;
mod build;
mod bundle;
mod completions;
mod dev;
mod error;
mod helpers;
mod icon;
mod info;
mod init;
mod inspect;
mod interface;
mod migrate;
mod mobile;
mod plugin;
mod remove;
mod signer;
use clap::{ArgAction, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
use env_logger::Builder;
use env_logger::fmt::style::{AnsiColor, Style};
pub use error::{Error, ErrorExt, Result};
use log::Level;
use serde::{Deserialize, Serialize};
use std::io::{BufReader, Write};
use std::process::{Command, ExitStatus, Output, Stdio, exit};
use std::{
ffi::OsString,
fmt::Display,
fs::read_to_string,
io::BufRead,
path::PathBuf,
str::FromStr,
sync::{Arc, Mutex},
};
use crate::error::Context;
/// Tauri configuration argument option.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigValue(pub(crate) serde_json::Value);
impl FromStr for ConfigValue {
type Err = Error;
fn from_str(config: &str) -> std::result::Result<Self, Self::Err> {
if config.starts_with('{') {
Ok(Self(serde_json::from_str(config).with_context(|| {
format!("failed to parse config `{config}` as JSON")
})?))
} else {
let path = PathBuf::from(config);
let raw =
read_to_string(&path).fs_context("failed to read configuration file", path.clone())?;
match path.extension().and_then(|ext| ext.to_str()) {
Some("toml") => Ok(Self(::toml::from_str(&raw).with_context(|| {
format!("failed to parse config at {} as TOML", path.display())
})?)),
Some("json5") => Ok(Self(::json5::from_str(&raw).with_context(|| {
format!("failed to parse config at {} as JSON5", path.display())
})?)),
// treat all other extensions as json
_ => Ok(Self(
// from tauri-utils/src/config/parse.rs:
// we also want to support **valid** json5 in the .json extension
// if the json5 is not valid the serde_json error for regular json will be returned.
match ::json5::from_str(&raw) {
Ok(json5) => json5,
Err(_) => serde_json::from_str(&raw)
.with_context(|| format!("failed to parse config at {} as JSON", path.display()))?,
},
)),
}
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
pub enum RunMode {
Desktop,
#[cfg(target_os = "macos")]
Ios,
Android,
}
impl Display for RunMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
Self::Desktop => "desktop",
#[cfg(target_os = "macos")]
Self::Ios => "iOS",
Self::Android => "android",
}
)
}
}
#[derive(Deserialize)]
pub struct VersionMetadata {
tauri: String,
#[serde(rename = "tauri-build")]
tauri_build: String,
#[serde(rename = "tauri-plugin")]
tauri_plugin: String,
}
#[derive(Deserialize)]
pub struct PackageJson {
name: Option<String>,
version: Option<String>,
product_name: Option<String>,
}
#[derive(Parser)]
#[clap(
author,
version,
about,
bin_name("cargo-tauri"),
subcommand_required(true),
arg_required_else_help(true),
propagate_version(true),
no_binary_name(true)
)]
pub(crate) struct Cli {
/// Enables verbose logging
#[clap(short, long, global = true, action = ArgAction::Count)]
verbose: u8,
#[clap(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Init(init::Options),
Dev(dev::Options),
Build(build::Options),
Bundle(bundle::Options),
Android(mobile::android::Cli),
#[cfg(target_os = "macos")]
Ios(mobile::ios::Cli),
/// Migrate from v1 to v2
Migrate,
Info(info::Options),
Add(add::Options),
Remove(remove::Options),
Plugin(plugin::Cli),
Icon(icon::Options),
Signer(signer::Cli),
Completions(completions::Options),
Permission(acl::permission::Cli),
Capability(acl::capability::Cli),
Inspect(inspect::Cli),
}
fn format_error<I: CommandFactory>(err: clap::Error) -> clap::Error {
let mut app = I::command();
err.format(&mut app)
}
fn get_verbosity(cli_verbose: u8) -> u8 {
std::env::var("TAURI_CLI_VERBOSITY")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(cli_verbose)
}
/// Run the Tauri CLI with the passed arguments, exiting if an error occurs.
///
/// The passed arguments should have the binary argument(s) stripped out before being passed.
///
/// e.g.
/// 1. `tauri-cli 1 2 3` -> `1 2 3`
/// 2. `cargo tauri 1 2 3` -> `1 2 3`
/// 3. `node tauri.js 1 2 3` -> `1 2 3`
///
/// The passed `bin_name` parameter should be how you want the help messages to display the command.
/// This defaults to `cargo-tauri`, but should be set to how the program was called, such as
/// `cargo tauri`.
pub fn run<I, A>(args: I, bin_name: Option<String>)
where
I: IntoIterator<Item = A>,
A: Into<OsString> + Clone,
{
if let Err(e) = try_run(args, bin_name) {
log::error!("{e}");
exit(1);
}
}
/// Run the Tauri CLI with the passed arguments.
///
/// It is similar to [`run`], but instead of exiting on an error, it returns a result.
pub fn try_run<I, A>(args: I, bin_name: Option<String>) -> Result<()>
where
I: IntoIterator<Item = A>,
A: Into<OsString> + Clone,
{
let cli = match bin_name {
Some(bin_name) => Cli::command().bin_name(bin_name),
None => Cli::command(),
};
let cli_ = cli.clone();
let matches = cli.get_matches_from(args);
let res = Cli::from_arg_matches(&matches).map_err(format_error::<Cli>);
let cli = match res {
Ok(s) => s,
Err(e) => e.exit(),
};
// set the verbosity level so subsequent CLI calls (xcode-script, android-studio-script) refer to it
let verbosity_number = get_verbosity(cli.verbose);
unsafe { std::env::set_var("TAURI_CLI_VERBOSITY", verbosity_number.to_string()) };
let mut builder = Builder::from_default_env();
if let Err(err) = builder
.format_indent(Some(12))
.filter(None, verbosity_level(verbosity_number).to_level_filter())
// goblin spams an insane amount of really technical logs on the debug level so we're reducing one level.
// goblin was removed in 2.12 but is still used in apple-codesign so we keep this just in case.
.filter(
Some("goblin"),
verbosity_level(verbosity_number.saturating_sub(1)).to_level_filter(),
)
// handlebars is not that spammy but its debug logs are typically far from being helpful
.filter(
Some("handlebars"),
verbosity_level(verbosity_number.saturating_sub(1)).to_level_filter(),
)
// `ureq_proto` logs out every network packets at trace level
.filter(
Some("ureq_proto"),
verbosity_level(verbosity_number.saturating_sub(1)).to_level_filter(),
)
.format(|f, record| {
let mut is_command_output = false;
if let Some(action) = record.key_values().get("action".into()) {
let action = action.to_cow_str().unwrap();
is_command_output = action == "stdout" || action == "stderr";
if !is_command_output {
let style = Style::new().fg_color(Some(AnsiColor::Green.into())).bold();
write!(f, "{style}{action:>12}{style:#} ")?;
}
} else {
let style = f.default_level_style(record.level()).bold();
write!(
f,
"{style}{:>12}{style:#} ",
prettyprint_level(record.level())
)?;
}
if !is_command_output && log::log_enabled!(Level::Debug) {
let style = Style::new().fg_color(Some(AnsiColor::Black.into()));
write!(f, "[{style}{}{style:#}] ", record.target())?;
}
writeln!(f, "{}", record.args())
})
.try_init()
{
eprintln!("Failed to attach logger: {err}");
}
match cli.command {
Commands::Build(options) => build::command(options, cli.verbose)?,
Commands::Bundle(options) => bundle::command(options, cli.verbose)?,
Commands::Dev(options) => dev::command(options)?,
Commands::Add(options) => add::command(options)?,
Commands::Remove(options) => remove::command(options)?,
Commands::Icon(options) => icon::command(options)?,
Commands::Info(options) => info::command(options)?,
Commands::Init(options) => init::command(options)?,
Commands::Plugin(cli) => plugin::command(cli)?,
Commands::Signer(cli) => signer::command(cli)?,
Commands::Completions(options) => completions::command(options, cli_)?,
Commands::Permission(options) => acl::permission::command(options)?,
Commands::Capability(options) => acl::capability::command(options)?,
Commands::Android(c) => mobile::android::command(c, cli.verbose)?,
#[cfg(target_os = "macos")]
Commands::Ios(c) => mobile::ios::command(c, cli.verbose)?,
Commands::Migrate => migrate::command()?,
Commands::Inspect(cli) => inspect::command(cli)?,
}
Ok(())
}
/// This maps the occurrence of `--verbose` flags to the correct log level
fn verbosity_level(num: u8) -> Level {
match num {
0 => Level::Info,
1 => Level::Debug,
_ => Level::Trace,
}
}
/// The default string representation for `Level` is all uppercaps which doesn't mix well with the other printed actions.
fn prettyprint_level(lvl: Level) -> &'static str {
match lvl {
Level::Error => "Error",
Level::Warn => "Warn",
Level::Info => "Info",
Level::Debug => "Debug",
Level::Trace => "Trace",
}
}
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<Output>;
}
impl CommandExt for Command {
fn piped(&mut self) -> std::io::Result<ExitStatus> {
self.stdin(os_pipe::dup_stdin()?);
self.stdout(os_pipe::dup_stdout()?);
self.stderr(os_pipe::dup_stderr()?);
let program = self.get_program().to_string_lossy().into_owned();
let args = self
.get_args()
.map(|a| a.to_string_lossy())
.collect::<Vec<_>>()
.join(" ");
log::debug!(action = "Running"; "Command `{program} {args}`");
self.status()
}
fn output_ok(&mut self) -> crate::Result<Output> {
let program = self.get_program().to_string_lossy().into_owned();
let args = self
.get_args()
.map(|a| a.to_string_lossy())
.collect::<Vec<_>>()
.join(" ");
let cmdline = format!("{program} {args}");
log::debug!(action = "Running"; "Command `{cmdline}`");
self.stdout(Stdio::piped());
self.stderr(Stdio::piped());
let mut child = self
.spawn()
.with_context(|| format!("failed to run command `{cmdline}`"))?;
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 line = String::new();
if let Ok(mut lines) = stdout_lines_.lock() {
loop {
line.clear();
match stdout.read_line(&mut line) {
Ok(0) => break,
Ok(_) => {
log::debug!(action = "stdout"; "{}", line.trim_end());
lines.extend(line.as_bytes());
}
Err(_) => (),
}
}
}
});
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 line = String::new();
if let Ok(mut lines) = stderr_lines_.lock() {
loop {
line.clear();
match stderr.read_line(&mut line) {
Ok(0) => break,
Ok(_) => {
log::debug!(action = "stderr"; "{}", line.trim_end());
lines.extend(line.as_bytes());
}
Err(_) => (),
}
}
}
});
let status = child
.wait()
.with_context(|| format!("failed to run command `{cmdline}`"))?;
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 {
crate::error::bail!(
"failed to run command `{cmdline}`: command exited with status code {}",
output.status.code().unwrap_or(-1)
);
}
}
}
#[cfg(test)]
mod tests {
use clap::CommandFactory;
use crate::Cli;
#[test]
fn verify_cli() {
Cli::command().debug_assert();
}
#[test]
fn help_output_includes_build() {
let help = Cli::command().render_help().to_string();
assert!(help.contains("Build"));
}
}

View File

@@ -0,0 +1,43 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
fn main() {
println!("The Tauri CLI is not supported on this platform");
std::process::exit(1);
}
#[cfg(any(target_os = "macos", target_os = "linux", windows))]
fn main() {
use std::env::args_os;
use std::ffi::OsStr;
use std::path::Path;
use std::process::exit;
let mut args = args_os().peekable();
let bin_name = match args
.next()
.as_deref()
.map(Path::new)
.and_then(Path::file_stem)
.and_then(OsStr::to_str)
{
Some("cargo-tauri") => {
if args.peek().and_then(|s| s.to_str()) == Some("tauri") {
// remove the extra cargo subcommand
args.next();
Some("cargo tauri".into())
} else {
Some("cargo-tauri".into())
}
}
Some(stem) => Some(stem.to_string()),
None => {
eprintln!("cargo-tauri wrapper unable to read first argument");
exit(1);
}
};
tauri_cli::run(args, bin_name)
}

View File

@@ -0,0 +1,6 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
pub mod v1;
pub mod v2_beta;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,136 @@
{
"$schema": "../../../../../../tauri-schema-generator/schemas/config.schema.json",
"build": {
"distDir": "../dist",
"devPath": "http://localhost:5173",
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build"
},
"package": {
"productName": "Tauri API",
"version": "1.0.0"
},
"tauri": {
"pattern": {
"use": "isolation",
"options": {
"dir": "../isolation-dist/"
}
},
"macOSPrivateApi": true,
"cli": {
"description": "Tauri API example",
"args": [
{
"short": "c",
"name": "config",
"takesValue": true,
"description": "Config path"
},
{
"short": "t",
"name": "theme",
"takesValue": true,
"description": "App theme",
"possibleValues": ["light", "dark", "system"]
},
{
"short": "v",
"name": "verbose",
"multipleOccurrences": true,
"description": "Verbosity level"
}
],
"subcommands": {
"update": {
"description": "Updates the app",
"args": [
{
"short": "b",
"name": "background",
"description": "Update in background"
}
]
}
}
},
"bundle": {
"active": true,
"identifier": "com.tauri.api",
"icon": [
"../../.icons/32x32.png",
"../../.icons/128x128.png",
"../../.icons/128x128@2x.png",
"../../.icons/icon.icns",
"../../.icons/icon.ico"
],
"windows": {
"wix": {
"language": {
"en-US": {},
"pt-BR": {
"localePath": "locales/pt-BR.wxl"
}
}
}
}
},
"updater": {
"active": true,
"dialog": false,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE5QzMxNjYwNTM5OEUwNTgKUldSWTRKaFRZQmJER1h4d1ZMYVA3dnluSjdpN2RmMldJR09hUFFlZDY0SlFqckkvRUJhZDJVZXAK",
"endpoints": [
"https://tauri-update-server.vercel.app/update/{{target}}/{{current_version}}"
]
},
"allowlist": {
"all": true,
"fs": {
"scope": {
"allow": ["$APPDATA/db/**", "$DOWNLOAD/**", "$RESOURCE/**"],
"deny": ["$APPDATA/db/*.stronghold"]
}
},
"shell": {
"open": true,
"scope": [
{
"name": "sh",
"cmd": "sh",
"args": ["-c", { "validator": "\\S+" }]
},
{
"name": "cmd",
"cmd": "cmd",
"args": ["/C", { "validator": "\\S+" }]
}
]
},
"protocol": {
"asset": true,
"assetScope": {
"allow": ["$APPDATA/db/**", "$RESOURCE/**"],
"deny": ["$APPDATA/db/*.stronghold"]
}
},
"http": {
"scope": ["http://localhost:3003"]
}
},
"windows": [],
"security": {
"csp": {
"default-src": "'self' customprotocol: asset:",
"font-src": ["https://fonts.gstatic.com"],
"img-src": "'self' asset: https://asset.localhost blob: data:",
"style-src": "'unsafe-inline' 'self' https://fonts.googleapis.com"
},
"freezePrototype": true
},
"systemTray": {
"iconPath": "../../.icons/tray_icon_with_transparency.png",
"iconAsTemplate": true,
"menuOnLeftClick": false
}
}
}

View File

@@ -0,0 +1,65 @@
{
"package": {
"productName": "{{ app_name }}",
"version": "0.1.0"
},
"build": {
"distDir": "{{ dist_dir }}",
"devPath": "{{ dev_path }}",
"beforeDevCommand": "{{ before_dev_command }}",
"beforeBuildCommand": "{{ before_build_command }}"
},
"tauri": {
"bundle": {
"active": true,
"targets": "all",
"identifier": "com.tauri.dev",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"resources": [],
"externalBin": [],
"copyright": "",
"category": "DeveloperTool",
"shortDescription": "",
"longDescription": "",
"deb": {
"depends": []
},
"macOS": {
"frameworks": [],
"exceptionDomain": "",
"signingIdentity": null,
"providerShortName": null,
"entitlements": null
},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
}
},
"updater": {
"active": false
},
"allowlist": {
"all": false
},
"windows": [
{
"title": "{{ window_title }}",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
}
}

View File

@@ -0,0 +1,736 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::{
Error, ErrorExt, Result,
error::Context,
helpers::{app_paths::walk_builder, npm::PackageManager},
};
use itertools::Itertools;
use magic_string::MagicString;
use oxc_allocator::Allocator;
use oxc_ast::ast::*;
use oxc_parser::Parser;
use oxc_span::SourceType;
use std::{fs, path::Path};
mod partial_loader;
const RENAMED_MODULES: phf::Map<&str, &str> = phf::phf_map! {
"tauri" => "core",
"window" => "webviewWindow"
};
const PLUGINIFIED_MODULES: [&str; 11] = [
"cli",
"clipboard",
"dialog",
"fs",
"globalShortcut",
"http",
"notification",
"os",
"process",
"shell",
"updater",
];
// (from, to)
const MODULES_MAP: phf::Map<&str, &str> = phf::phf_map! {
// renamed
"@tauri-apps/api/tauri" => "@tauri-apps/api/core",
"@tauri-apps/api/window" => "@tauri-apps/api/webviewWindow",
// pluginified
"@tauri-apps/api/cli" => "@tauri-apps/plugin-cli",
"@tauri-apps/api/clipboard" => "@tauri-apps/plugin-clipboard-manager",
"@tauri-apps/api/dialog" => "@tauri-apps/plugin-dialog",
"@tauri-apps/api/fs" => "@tauri-apps/plugin-fs",
"@tauri-apps/api/globalShortcut" => "@tauri-apps/plugin-global-shortcut",
"@tauri-apps/api/http" => "@tauri-apps/plugin-http",
"@tauri-apps/api/notification" => "@tauri-apps/plugin-notification",
"@tauri-apps/api/os" => "@tauri-apps/plugin-os",
"@tauri-apps/api/process" => "@tauri-apps/plugin-process",
"@tauri-apps/api/shell" => "@tauri-apps/plugin-shell",
"@tauri-apps/api/updater" => "@tauri-apps/plugin-updater",
// v1 plugins to v2
"tauri-plugin-sql-api" => "@tauri-apps/plugin-sql",
"tauri-plugin-store-api" => "@tauri-apps/plugin-store",
"tauri-plugin-upload-api" => "@tauri-apps/plugin-upload",
"tauri-plugin-fs-extra-api" => "@tauri-apps/plugin-fs",
"tauri-plugin-fs-watch-api" => "@tauri-apps/plugin-fs",
"tauri-plugin-autostart-api" => "@tauri-apps/plugin-autostart",
"tauri-plugin-websocket-api" => "@tauri-apps/plugin-websocket",
"tauri-plugin-positioner-api" => "@tauri-apps/plugin-positioner",
"tauri-plugin-stronghold-api" => "@tauri-apps/plugin-stronghold",
"tauri-plugin-window-state-api" => "@tauri-apps/plugin-window-state",
"tauri-plugin-authenticator-api" => "@tauri-apps/plugin-authenticator",
};
const JS_EXTENSIONS: &[&str] = &["js", "mjs", "jsx", "ts", "mts", "tsx", "svelte", "vue"];
/// Returns a list of migrated plugins
pub fn migrate(frontend_dir: &Path) -> Result<Vec<String>> {
let mut new_npm_packages = Vec::new();
let mut new_plugins = Vec::new();
let mut npm_packages_to_remove = Vec::new();
let pre = env!("CARGO_PKG_VERSION_PRE");
let npm_version = if pre.is_empty() {
format!("{}.0.0", env!("CARGO_PKG_VERSION_MAJOR"))
} else {
format!(
"{}.0.0-{}.0",
env!("CARGO_PKG_VERSION_MAJOR"),
pre.split('.').next().unwrap()
)
};
let pm = PackageManager::from_project(frontend_dir);
for pkg in ["@tauri-apps/cli", "@tauri-apps/api"] {
let version = pm
.current_package_version(pkg, frontend_dir)
.unwrap_or_default()
.unwrap_or_default();
if version.starts_with('1') {
new_npm_packages.push(format!("{pkg}@^{npm_version}"));
}
}
for entry in walk_builder(frontend_dir).build().flatten() {
if entry.file_type().map(|t| t.is_file()).unwrap_or_default() {
let path = entry.path();
let ext = path.extension().unwrap_or_default();
if JS_EXTENSIONS.iter().any(|e| e == &ext) {
let js_contents =
std::fs::read_to_string(path).fs_context("failed to read JS file", path.to_path_buf())?;
let new_contents = migrate_imports(
path,
&js_contents,
&mut new_plugins,
&mut npm_packages_to_remove,
)?;
if new_contents != js_contents {
fs::write(path, new_contents)
.fs_context("failed to write JS file", path.to_path_buf())?;
}
}
}
}
if !npm_packages_to_remove.is_empty() {
npm_packages_to_remove.sort();
npm_packages_to_remove.dedup();
pm.remove(&npm_packages_to_remove, frontend_dir)
.context("Error removing npm packages")?;
}
if !new_npm_packages.is_empty() {
new_npm_packages.sort();
new_npm_packages.dedup();
pm.install(&new_npm_packages, frontend_dir)
.context("Error installing new npm packages")?;
}
Ok(new_plugins)
}
fn migrate_imports<'a>(
path: &'a Path,
js_source: &'a str,
new_plugins: &mut Vec<String>,
npm_packages_to_remove: &mut Vec<String>,
) -> crate::Result<String> {
let mut magic_js_source = MagicString::new(js_source);
let has_partial_js = path
.extension()
.is_some_and(|ext| ext == "vue" || ext == "svelte");
let sources = if !has_partial_js {
let mut source_type = SourceType::from_path(path).unwrap();
if source_type.is_javascript() {
// oxc_span used to do this for us but in 0.70 it was moved into the higher level oxc crates instead.
source_type = source_type.with_jsx(true);
}
vec![(source_type, js_source, 0i64)]
} else {
partial_loader::PartialLoader::parse(
path
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
js_source,
)
.unwrap()
.into_iter()
.map(|s| (s.source_type, s.source_text, s.start as i64))
.collect()
};
for (source_type, js_source, script_start) in sources {
let allocator = Allocator::default();
let ret = Parser::new(&allocator, js_source, source_type).parse();
if !ret.errors.is_empty() {
crate::error::bail!(
"failed to parse {} as valid Javascript/Typescript file",
path.display()
)
}
let mut program = ret.program;
let mut stmts_to_add = Vec::new();
let mut imports_to_add = Vec::new();
for import in program.body.iter_mut() {
if let Statement::ImportDeclaration(stmt) = import {
let module = stmt.source.value.as_str();
// convert module to its pluginfied module or renamed one
// import { ... } from "@tauri-apps/api/window" -> import { ... } from "@tauri-apps/api/webviewWindow"
// import { ... } from "@tauri-apps/api/cli" -> import { ... } from "@tauri-apps/plugin-cli"
if let Some(&new_module) = MODULES_MAP.get(module) {
// +1 and -1, to skip modifying the import quotes
magic_js_source
.overwrite(
script_start + stmt.source.span.start as i64 + 1,
script_start + stmt.source.span.end as i64 - 1,
new_module,
Default::default(),
)
.map_err(|e| {
Error::Context(
"failed to replace import source".to_string(),
e.to_string().into(),
)
})?;
// if module was pluginified, add to packages
if let Some(plugin_name) = new_module.strip_prefix("@tauri-apps/plugin-") {
new_plugins.push(plugin_name.to_string());
}
// if the module is a v1 plugin, we should remove it
if module.starts_with("tauri-plugin-") {
npm_packages_to_remove.push(module.to_string());
}
}
// skip parsing non @tauri-apps/api imports
if !module.starts_with("@tauri-apps/api") {
continue;
}
let Some(specifiers) = &mut stmt.specifiers else {
continue;
};
for specifier in specifiers.iter() {
if let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier {
let new_identifier = match specifier.imported.name().as_str() {
// migrate appWindow from:
// ```
// import { appWindow } from "@tauri-apps/api/window"
// ```
// to:
// ```
// import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"
// const appWindow = getCurrentWebviewWindow()
// ```
"appWindow" if module == "@tauri-apps/api/window" => {
stmts_to_add.push("\nconst appWindow = getCurrentWebviewWindow()");
Some("getCurrentWebviewWindow")
}
// migrate pluginified modules from:
// ```
// import { dialog, cli as superCli } from "@tauri-apps/api"
// ```
// to:
// ```
// import * as dialog from "@tauri-apps/plugin-dialog"
// import * as superCli from "@tauri-apps/plugin-cli"
// ```
import if PLUGINIFIED_MODULES.contains(&import) && module == "@tauri-apps/api" => {
let js_plugin: &str = MODULES_MAP[&format!("@tauri-apps/api/{import}")];
let (_, plugin_name) = js_plugin.split_once("plugin-").unwrap();
new_plugins.push(plugin_name.to_string());
if specifier.local.name.as_str() != import {
let local = &specifier.local.name;
imports_to_add.push(format!("\nimport * as {local} from \"{js_plugin}\""));
} else {
imports_to_add.push(format!("\nimport * as {import} from \"{js_plugin}\""));
};
None
}
import if module == "@tauri-apps/api" => match RENAMED_MODULES.get(import) {
Some(m) => Some(*m),
None => continue,
},
// nothing to do, go to next specifier
_ => continue,
};
// if identifier was renamed, it will be Some()
// and so we convert the import
// import { appWindow } from "@tauri-apps/api/window" -> import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"
if let Some(new_identifier) = new_identifier {
magic_js_source
.overwrite(
script_start + specifier.span.start as i64,
script_start + specifier.span.end as i64,
new_identifier,
Default::default(),
)
.map_err(|e| {
Error::Context(
"failed to rename identifier".to_string(),
e.to_string().into(),
)
})?;
} else {
// if None, we need to remove this specifier,
// it will also be replaced with an import from its new plugin below
// find the next comma or the bracket ending the import
let start = specifier.span.start as usize;
let sliced = &js_source[start..];
let comma_or_bracket = sliced.chars().find_position(|&c| c == ',' || c == '}');
let end = match comma_or_bracket {
Some((n, ',')) => n + start + 1,
Some((_, '}')) => specifier.span.end as _,
_ => continue,
};
magic_js_source
.remove(script_start + start as i64, script_start + end as i64)
.map_err(|e| {
Error::Context(
"failed to remove identifier".to_string(),
e.to_string().into(),
)
})?;
}
}
}
}
}
// find the end of import list
// fallback to the program start
let start = program
.body
.iter()
.rev()
.find(|s| matches!(s, Statement::ImportDeclaration(_)))
.map(|s| match s {
Statement::ImportDeclaration(s) => s.span.end,
_ => unreachable!(),
})
.unwrap_or(program.span.start);
if !imports_to_add.is_empty() {
for import in imports_to_add {
magic_js_source
.append_right(script_start as u32 + start, &import)
.map_err(|e| Error::Context("failed to add import".to_string(), e.to_string().into()))?;
}
}
if !stmts_to_add.is_empty() {
for stmt in stmts_to_add {
magic_js_source
.append_right(script_start as u32 + start, stmt)
.map_err(|e| {
Error::Context("failed to add statement".to_string(), e.to_string().into())
})?;
}
}
}
Ok(magic_js_source.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn assert_migrated_output_parses(path: &Path, source: &str) {
let has_partial_js = path
.extension()
.is_some_and(|ext| ext == "vue" || ext == "svelte");
let sources = if !has_partial_js {
let mut source_type = SourceType::from_path(path).unwrap();
if source_type.is_javascript() {
// oxc_span used to do this for us but in 0.70 it was moved into the higher level oxc crates instead.
source_type = source_type.with_jsx(true);
}
vec![(source_type, source.to_string())]
} else {
partial_loader::PartialLoader::parse(
path
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
source,
)
.unwrap()
.into_iter()
.map(|s| (s.source_type, s.source_text.to_string()))
.collect()
};
for (source_type, script_source) in sources {
let allocator = Allocator::default();
let ret = Parser::new(&allocator, &script_source, source_type).parse();
assert!(
ret.errors.is_empty(),
"migrated output did not parse: {:?}",
ret.errors
);
}
}
#[test]
fn migrates_vue() {
let input = r#"
<template>
<div>Tauri!</div>
</template>
<script setup>
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke, dialog, cli as superCli } from "@tauri-apps/api";
import { appWindow } from "@tauri-apps/api/window";
import { convertFileSrc } from "@tauri-apps/api/tauri";
import { open } from "@tauri-apps/api/dialog";
import { register } from "@tauri-apps/api/globalShortcut";
import clipboard from "@tauri-apps/api/clipboard";
import * as fs from "@tauri-apps/api/fs";
import "./App.css";
</script>
<style>
.greeting {
color: red;
font-weight: bold;
}
</style>
"#;
let expected = r#"
<template>
<div>Tauri!</div>
</template>
<script setup>
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke, } from "@tauri-apps/api";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { convertFileSrc } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { register } from "@tauri-apps/plugin-global-shortcut";
import clipboard from "@tauri-apps/plugin-clipboard-manager";
import * as fs from "@tauri-apps/plugin-fs";
import "./App.css";
import * as dialog from "@tauri-apps/plugin-dialog"
import * as superCli from "@tauri-apps/plugin-cli"
const appWindow = getCurrentWebviewWindow()
</script>
<style>
.greeting {
color: red;
font-weight: bold;
}
</style>
"#;
let mut new_plugins = Vec::new();
let mut npm_packages_to_remove = Vec::new();
let migrated = migrate_imports(
Path::new("file.vue"),
input,
&mut new_plugins,
&mut npm_packages_to_remove,
)
.unwrap();
assert_eq!(migrated, expected);
assert_migrated_output_parses(Path::new("file.vue"), &migrated);
assert_eq!(
new_plugins,
vec![
"dialog",
"cli",
"dialog",
"global-shortcut",
"clipboard-manager",
"fs"
]
);
assert_eq!(npm_packages_to_remove, Vec::<String>::new());
}
#[test]
fn migrates_svelte() {
let input = r#"
<form>
</form>
<script>
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke, dialog, cli as superCli } from "@tauri-apps/api";
import { appWindow } from "@tauri-apps/api/window";
import { convertFileSrc } from "@tauri-apps/api/tauri";
import { open } from "@tauri-apps/api/dialog";
import { register } from "@tauri-apps/api/globalShortcut";
import clipboard from "@tauri-apps/api/clipboard";
import * as fs from "@tauri-apps/api/fs";
import "./App.css";
</script>
"#;
let expected = r#"
<form>
</form>
<script>
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke, } from "@tauri-apps/api";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { convertFileSrc } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { register } from "@tauri-apps/plugin-global-shortcut";
import clipboard from "@tauri-apps/plugin-clipboard-manager";
import * as fs from "@tauri-apps/plugin-fs";
import "./App.css";
import * as dialog from "@tauri-apps/plugin-dialog"
import * as superCli from "@tauri-apps/plugin-cli"
const appWindow = getCurrentWebviewWindow()
</script>
"#;
let mut new_plugins = Vec::new();
let mut npm_packages_to_remove = Vec::new();
let migrated = migrate_imports(
Path::new("file.svelte"),
input,
&mut new_plugins,
&mut npm_packages_to_remove,
)
.unwrap();
assert_eq!(migrated, expected);
assert_migrated_output_parses(Path::new("file.svelte"), &migrated);
assert_eq!(
new_plugins,
vec![
"dialog",
"cli",
"dialog",
"global-shortcut",
"clipboard-manager",
"fs"
]
);
assert_eq!(npm_packages_to_remove, Vec::<String>::new());
}
#[test]
fn migrates_js() {
let input = r#"
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke, dialog, cli as superCli } from "@tauri-apps/api";
import { appWindow } from "@tauri-apps/api/window";
import { convertFileSrc } from "@tauri-apps/api/tauri";
import { open } from "@tauri-apps/api/dialog";
import { register } from "@tauri-apps/api/globalShortcut";
import clipboard from "@tauri-apps/api/clipboard";
import * as fs from "@tauri-apps/api/fs";
import { Store } from "tauri-plugin-store-api";
import Database from "tauri-plugin-sql-api";
import "./App.css";
function App() {
const [greetMsg, setGreetMsg] = useState("");
const [name, setName] = useState("");
async function greet() {
// Learn more about Tauri commands at https://v2.tauri.app/develop/calling-rust/#commands
setGreetMsg(await invoke("greet", { name }));
await open();
await dialog.save();
await convertFileSrc("");
const a = appWindow.label;
superCli.getMatches();
clipboard.readText();
fs.exists("");
}
return (
<div className="container">
<h1>Welcome to Tauri!</h1>
<div className="row">
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" className="logo vite" alt="Vite logo" />
</a>
<a href="https://tauri.app" target="_blank">
<img src="/tauri.svg" className="logo tauri" alt="Tauri logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<p>Click on the Tauri, Vite, and React logos to learn more.</p>
<form
className="row"
onSubmit={(e) => {
e.preventDefault();
greet();
}}
>
<input
id="greet-input"
onChange={(e) => setName(e.currentTarget.value)}
placeholder="Enter a name..."
/>
<button type="submit">Greet</button>
</form>
<p>{greetMsg}</p>
</div>
);
}
export default App;
"#;
let expected = r#"
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke, } from "@tauri-apps/api";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { convertFileSrc } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { register } from "@tauri-apps/plugin-global-shortcut";
import clipboard from "@tauri-apps/plugin-clipboard-manager";
import * as fs from "@tauri-apps/plugin-fs";
import { Store } from "@tauri-apps/plugin-store";
import Database from "@tauri-apps/plugin-sql";
import "./App.css";
import * as dialog from "@tauri-apps/plugin-dialog"
import * as superCli from "@tauri-apps/plugin-cli"
const appWindow = getCurrentWebviewWindow()
function App() {
const [greetMsg, setGreetMsg] = useState("");
const [name, setName] = useState("");
async function greet() {
// Learn more about Tauri commands at https://v2.tauri.app/develop/calling-rust/#commands
setGreetMsg(await invoke("greet", { name }));
await open();
await dialog.save();
await convertFileSrc("");
const a = appWindow.label;
superCli.getMatches();
clipboard.readText();
fs.exists("");
}
return (
<div className="container">
<h1>Welcome to Tauri!</h1>
<div className="row">
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" className="logo vite" alt="Vite logo" />
</a>
<a href="https://tauri.app" target="_blank">
<img src="/tauri.svg" className="logo tauri" alt="Tauri logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<p>Click on the Tauri, Vite, and React logos to learn more.</p>
<form
className="row"
onSubmit={(e) => {
e.preventDefault();
greet();
}}
>
<input
id="greet-input"
onChange={(e) => setName(e.currentTarget.value)}
placeholder="Enter a name..."
/>
<button type="submit">Greet</button>
</form>
<p>{greetMsg}</p>
</div>
);
}
export default App;
"#;
let mut new_plugins = Vec::new();
let mut npm_packages_to_remove = Vec::new();
let migrated = migrate_imports(
Path::new("file.js"),
input,
&mut new_plugins,
&mut npm_packages_to_remove,
)
.unwrap();
assert_eq!(migrated, expected);
assert_migrated_output_parses(Path::new("file.js"), &migrated);
assert_eq!(
new_plugins,
vec![
"dialog",
"cli",
"dialog",
"global-shortcut",
"clipboard-manager",
"fs",
"store",
"sql"
]
);
assert_eq!(
npm_packages_to_remove,
vec!["tauri-plugin-store-api", "tauri-plugin-sql-api"]
);
}
}

View File

@@ -0,0 +1,69 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
// taken from https://github.com/oxc-project/oxc/blob/main/crates/oxc_linter/src/partial_loader/mod.rs
mod svelte;
mod vue;
use oxc_span::SourceType;
pub use self::{svelte::SveltePartialLoader, vue::VuePartialLoader};
const SCRIPT_START: &str = "<script";
const SCRIPT_END: &str = "</script>";
#[derive(Debug, Clone, Copy)]
pub struct JavaScriptSource<'a> {
pub source_text: &'a str,
pub source_type: SourceType,
/// The javascript source could be embedded in some file,
/// use `start` to record start offset of js block in the original file.
pub start: usize,
}
impl<'a> JavaScriptSource<'a> {
pub fn new(source_text: &'a str, source_type: SourceType, start: usize) -> Self {
Self {
source_text,
source_type,
start,
}
}
}
pub struct PartialLoader;
impl PartialLoader {
/// Extract js section of specifial files.
/// Returns `None` if the specifial file does not have a js section.
pub fn parse<'a>(ext: &str, source_text: &'a str) -> Option<Vec<JavaScriptSource<'a>>> {
match ext {
"vue" => Some(VuePartialLoader::new(source_text).parse()),
"svelte" => Some(SveltePartialLoader::new(source_text).parse()),
_ => None,
}
}
}
/// Find closing angle for situations where there is another `>` in between.
/// e.g. `<script generic="T extends Record<string, string>">`
fn find_script_closing_angle(source_text: &str, pointer: usize) -> Option<usize> {
let mut numbers_of_open_angle = 0;
for (offset, c) in source_text[pointer..].char_indices() {
match c {
'>' => {
if numbers_of_open_angle == 0 {
return Some(offset);
}
numbers_of_open_angle -= 1;
}
'<' => {
numbers_of_open_angle += 1;
}
_ => {}
}
}
None
}

View File

@@ -0,0 +1,93 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
// taken from https://github.com/oxc-project/oxc/blob/main/crates/oxc_linter/src/partial_loader/svelte.rs
use memchr::memmem::Finder;
use oxc_span::SourceType;
use super::{JavaScriptSource, SCRIPT_END, SCRIPT_START, find_script_closing_angle};
pub struct SveltePartialLoader<'a> {
source_text: &'a str,
}
impl<'a> SveltePartialLoader<'a> {
pub fn new(source_text: &'a str) -> Self {
Self { source_text }
}
pub fn parse(self) -> Vec<JavaScriptSource<'a>> {
self
.parse_script()
.map_or_else(Vec::new, |source| vec![source])
}
fn parse_script(&self) -> Option<JavaScriptSource<'a>> {
let script_start_finder = Finder::new(SCRIPT_START);
let script_end_finder = Finder::new(SCRIPT_END);
let mut pointer = 0;
// find opening "<script"
let offset = script_start_finder.find(&self.source_text.as_bytes()[pointer..])?;
pointer += offset + SCRIPT_START.len();
// find closing ">"
let offset = find_script_closing_angle(self.source_text, pointer)?;
// get lang="ts" attribute
let content = &self.source_text[pointer..pointer + offset];
let is_ts = content.contains("ts");
pointer += offset + 1;
let js_start = pointer;
// find "</script>"
let offset = script_end_finder.find(&self.source_text.as_bytes()[pointer..])?;
let js_end = pointer + offset;
let source_text = &self.source_text[js_start..js_end];
let source_type = SourceType::default()
.with_module(true)
.with_typescript(is_ts);
Some(JavaScriptSource::new(source_text, source_type, js_start))
}
}
#[cfg(test)]
mod test {
use super::{JavaScriptSource, SveltePartialLoader};
fn parse_svelte(source_text: &str) -> JavaScriptSource<'_> {
let sources = SveltePartialLoader::new(source_text).parse();
*sources.first().unwrap()
}
#[test]
fn test_parse_svelte() {
let source_text = r#"
<script>
console.log("hi");
</script>
<h1>Hello World</h1>
"#;
let result = parse_svelte(source_text);
assert_eq!(result.source_text.trim(), r#"console.log("hi");"#);
}
#[test]
fn test_parse_svelte_ts_with_generic() {
let source_text = r#"
<script lang="ts" generics="T extends Record<string, unknown>">
console.log("hi");
</script>
<h1>Hello World</h1>
"#;
let result = parse_svelte(source_text);
assert_eq!(result.source_text.trim(), r#"console.log("hi");"#);
}
}

View File

@@ -0,0 +1,246 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
// taken from https://github.com/oxc-project/oxc/blob/main/crates/oxc_linter/src/partial_loader/vue.rs
use memchr::memmem::Finder;
use oxc_span::SourceType;
use super::{JavaScriptSource, SCRIPT_END, SCRIPT_START, find_script_closing_angle};
pub struct VuePartialLoader<'a> {
source_text: &'a str,
}
impl<'a> VuePartialLoader<'a> {
pub fn new(source_text: &'a str) -> Self {
Self { source_text }
}
pub fn parse(self) -> Vec<JavaScriptSource<'a>> {
self.parse_scripts()
}
/// Each *.vue file can contain at most
/// * one `<script>` block (excluding `<script setup>`).
/// * one `<script setup>` block (excluding normal `<script>`).
/// <https://vuejs.org/api/sfc-spec.html#script>
fn parse_scripts(&self) -> Vec<JavaScriptSource<'a>> {
let mut pointer = 0;
let Some(result1) = self.parse_script(&mut pointer) else {
return vec![];
};
let Some(result2) = self.parse_script(&mut pointer) else {
return vec![result1];
};
vec![result1, result2]
}
fn parse_script(&self, pointer: &mut usize) -> Option<JavaScriptSource<'a>> {
let script_start_finder = Finder::new(SCRIPT_START);
let script_end_finder = Finder::new(SCRIPT_END);
// find opening "<script"
let offset = script_start_finder.find(&self.source_text.as_bytes()[*pointer..])?;
*pointer += offset + SCRIPT_START.len();
// find closing ">"
let offset = find_script_closing_angle(self.source_text, *pointer)?;
// get ts and jsx attribute
let content = &self.source_text[*pointer..*pointer + offset];
let is_ts = content.contains("ts");
let is_jsx = content.contains("tsx") || content.contains("jsx");
*pointer += offset + 1;
let js_start = *pointer;
// find "</script>"
let offset = script_end_finder.find(&self.source_text.as_bytes()[*pointer..])?;
let js_end = *pointer + offset;
*pointer += offset + SCRIPT_END.len();
let source_text = &self.source_text[js_start..js_end];
let source_type = SourceType::default()
.with_module(true)
.with_typescript(is_ts)
.with_jsx(is_jsx);
Some(JavaScriptSource::new(source_text, source_type, js_start))
}
}
#[cfg(test)]
mod test {
use super::{JavaScriptSource, VuePartialLoader};
fn parse_vue(source_text: &str) -> JavaScriptSource<'_> {
let sources = VuePartialLoader::new(source_text).parse();
*sources.first().unwrap()
}
#[test]
fn test_parse_vue_one_line() {
let source_text = r#"
<template>
<h1>hello world</h1>
</template>
<script> console.log("hi") </script>
"#;
let result = parse_vue(source_text);
assert_eq!(result.source_text, r#" console.log("hi") "#);
}
#[test]
fn test_build_vue_with_ts_flag_1() {
let source_text = r#"
<script lang="ts" setup generic="T extends Record<string, string>">
1/1
</script>
"#;
let result = parse_vue(source_text);
assert!(result.source_type.is_typescript());
assert_eq!(result.source_text.trim(), "1/1");
}
#[test]
fn test_build_vue_with_ts_flag_2() {
let source_text = r"
<script lang=ts setup>
1/1
</script>
";
let result = parse_vue(source_text);
assert!(result.source_type.is_typescript());
assert_eq!(result.source_text.trim(), "1/1");
}
#[test]
fn test_build_vue_with_ts_flag_3() {
let source_text = r"
<script lang='ts' setup>
1/1
</script>
";
let result = parse_vue(source_text);
assert!(result.source_type.is_typescript());
assert_eq!(result.source_text.trim(), "1/1");
}
#[test]
fn test_build_vue_with_tsx_flag() {
let source_text = r"
<script lang=tsx setup>
1/1
</script>
";
let result = parse_vue(source_text);
assert!(result.source_type.is_jsx());
assert!(result.source_type.is_typescript());
assert_eq!(result.source_text.trim(), "1/1");
}
#[test]
fn test_build_vue_with_escape_string() {
let source_text = r"
<script setup>
a.replace(/&#39;/g, '\''))
</script>
<template> </template>
";
let result = parse_vue(source_text);
assert!(!result.source_type.is_typescript());
assert_eq!(result.source_text.trim(), r"a.replace(/&#39;/g, '\''))");
}
#[test]
fn test_multi_level_template_literal() {
let source_text = r"
<script setup>
`a${b( `c \`${d}\``)}`
</script>
";
let result = parse_vue(source_text);
assert_eq!(result.source_text.trim(), r"`a${b( `c \`${d}\``)}`");
}
#[test]
fn test_brace_with_regex_in_template_literal() {
let source_text = r"
<script setup>
`${/{/}`
</script>
";
let result = parse_vue(source_text);
assert_eq!(result.source_text.trim(), r"`${/{/}`");
}
#[test]
fn test_no_script() {
let source_text = r"
<template></template>
";
let sources = VuePartialLoader::new(source_text).parse();
assert!(sources.is_empty());
}
#[test]
fn test_syntax_error() {
let source_text = r"
<script>
console.log('error')
";
let sources = VuePartialLoader::new(source_text).parse();
assert!(sources.is_empty());
}
#[test]
fn test_multiple_scripts() {
let source_text = r"
<template></template>
<script>a</script>
<script setup>b</script>
";
let sources = VuePartialLoader::new(source_text).parse();
assert_eq!(sources.len(), 2);
assert_eq!(sources[0].source_text, "a");
assert_eq!(sources[1].source_text, "b");
}
#[test]
fn test_unicode() {
let source_text = r"
<script setup>
let 日历 = '2000年';
const t = useTranslate({
'zh-CN': {
calendar: '日历',
tiledDisplay: '平铺展示',
},
});
</script>
";
let result = parse_vue(source_text);
assert_eq!(
result.source_text.trim(),
"let 日历 = '2000年';
const t = useTranslate({
'zh-CN': {
calendar: '日历',
tiledDisplay: '平铺展示',
},
});"
.trim()
);
}
}

View File

@@ -0,0 +1,415 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::{
Result,
error::ErrorExt,
interface::rust::manifest::{read_manifest, serialize_manifest},
};
use tauri_utils::config_v1::Allowlist;
use toml_edit::{DocumentMut, Entry, Item, TableLike, Value};
use std::path::Path;
const CRATE_TYPES: [&str; 3] = ["lib", "staticlib", "cdylib"];
pub fn migrate(tauri_dir: &Path) -> Result<()> {
let manifest_path = tauri_dir.join("Cargo.toml");
let (mut manifest, _) = read_manifest(&manifest_path)?;
migrate_manifest(&mut manifest)?;
std::fs::write(&manifest_path, serialize_manifest(&manifest))
.fs_context("failed to rewrite Cargo manifest", &manifest_path)?;
Ok(())
}
fn migrate_manifest(manifest: &mut DocumentMut) -> Result<()> {
let version = dependency_version();
let remove_features = features_to_remove();
let rename_features = features_to_rename();
let rename_message = rename_features
.iter()
.map(|(from, to)| format!("{from} to {to}"))
.collect::<Vec<_>>()
.join(", ");
for (dependency, table) in [
// normal deps
("tauri", "dependencies"),
("tauri-utils", "dependencies"),
("tauri-runtime", "dependencies"),
("tauri-codegen", "dependencies"),
("tauri-macros", "dependencies"),
("tauri-runtime-wry", "dependencies"),
// normal deps - plugins
("tauri-plugin-authenticator", "dependencies"),
("tauri-plugin-autostart", "dependencies"),
("tauri-plugin-fs-extra", "dependencies"),
("tauri-plugin-fs-watch", "dependencies"),
("tauri-plugin-localhost", "dependencies"),
("tauri-plugin-log", "dependencies"),
("tauri-plugin-persisted-scope", "dependencies"),
("tauri-plugin-positioner", "dependencies"),
("tauri-plugin-single-instance", "dependencies"),
("tauri-plugin-sql", "dependencies"),
("tauri-plugin-store", "dependencies"),
("tauri-plugin-stronghold", "dependencies"),
("tauri-plugin-upload", "dependencies"),
("tauri-plugin-websocket", "dependencies"),
("tauri-plugin-window-state", "dependencies"),
// dev
("tauri", "dev-dependencies"),
("tauri-utils", "dev-dependencies"),
("tauri-runtime", "dev-dependencies"),
("tauri-codegen", "dev-dependencies"),
("tauri-macros", "dev-dependencies"),
("tauri-runtime-wry", "dev-dependencies"),
// build
("tauri-build", "build-dependencies"),
] {
let items = find_dependency(manifest, dependency, table);
for item in items {
// do not rewrite if dependency uses workspace inheritance
if item
.get("workspace")
.and_then(|v| v.as_bool())
.unwrap_or_default()
{
log::warn!(
"`{dependency}` dependency has workspace inheritance enabled. This migration must be manually migrated to v2 by changing its version to {version}, removing any of the {remove_features:?} and renaming [{}] Cargo features.",
rename_message
);
} else {
migrate_dependency(item, &version, &remove_features, &rename_features);
}
}
}
if let Some(lib) = manifest
.as_table_mut()
.get_mut("lib")
.and_then(|l| l.as_table_mut())
{
match lib.entry("crate-type") {
Entry::Occupied(mut e) => {
if let Item::Value(Value::Array(types)) = e.get_mut() {
let mut crate_types_to_add = CRATE_TYPES.to_vec();
for t in types.iter() {
// type is already in the manifest, skip adding it
if let Some(i) = crate_types_to_add
.iter()
.position(|ty| Some(ty) == t.as_str().as_ref())
{
crate_types_to_add.remove(i);
}
}
for t in crate_types_to_add {
types.push(t);
}
}
}
Entry::Vacant(e) => {
let mut arr = toml_edit::Array::new();
arr.extend(CRATE_TYPES.to_vec());
e.insert(Item::Value(arr.into()));
}
}
}
Ok(())
}
fn find_dependency<'a>(
manifest: &'a mut DocumentMut,
name: &'a str,
table: &'a str,
) -> Vec<&'a mut Item> {
let m = manifest.as_table_mut();
for (k, v) in m.iter_mut() {
if let Some(t) = v.as_table_mut() {
if k == table {
if let Some(item) = t.get_mut(name) {
return vec![item];
}
} else if k == "target" {
let mut matching_deps = Vec::new();
for (_, target_value) in t.iter_mut() {
if let Some(target_table) = target_value.as_table_mut() {
if let Some(deps) = target_table.get_mut(table) {
if let Some(item) = deps.as_table_mut().and_then(|t| t.get_mut(name)) {
matching_deps.push(item);
}
}
}
}
return matching_deps;
}
}
}
Vec::new()
}
fn features_to_rename() -> Vec<(&'static str, &'static str)> {
vec![
("window-data-url", "webview-data-url"),
("reqwest-native-tls-vendored", "native-tls-vendored"),
("system-tray", "tray-icon"),
("icon-ico", "image-ico"),
("icon-png", "image-png"),
]
}
fn features_to_remove() -> Vec<&'static str> {
let mut features_to_remove = tauri_utils::config_v1::AllowlistConfig::all_features();
features_to_remove.extend(&[
"reqwest-client",
"http-multipart",
"process-command-api",
"shell-open-api",
"os-api",
"global-shortcut",
"clipboard",
"dialog",
"notification",
"fs-extract-api",
"windows7-compat",
"updater",
"cli",
"linux-protocol-headers",
"dox",
]);
// this allowlist feature was not removed
let index = features_to_remove
.iter()
.position(|x| x == &"protocol-asset")
.unwrap();
features_to_remove.remove(index);
features_to_remove
}
fn dependency_version() -> String {
let pre = env!("CARGO_PKG_VERSION_PRE");
if pre.is_empty() {
env!("CARGO_PKG_VERSION_MAJOR").to_string()
} else {
format!(
"{}.0.0-{}",
env!("CARGO_PKG_VERSION_MAJOR"),
pre.split('.').next().unwrap()
)
}
}
fn migrate_dependency(item: &mut Item, version: &str, remove: &[&str], rename: &[(&str, &str)]) {
if let Some(dep) = item.as_table_mut() {
migrate_dependency_table(dep, version, remove, rename);
} else if let Some(Value::InlineTable(table)) = item.as_value_mut() {
migrate_dependency_table(table, version, remove, rename);
} else if item.as_str().is_some() {
*item = Item::Value(version.into());
}
}
fn migrate_dependency_table<D: TableLike>(
dep: &mut D,
version: &str,
remove: &[&str],
rename: &[(&str, &str)],
) {
dep.remove("rev");
dep.remove("git");
dep.remove("branch");
dep.remove("tag");
*dep.entry("version").or_insert(Item::None) = Item::Value(version.into());
let manifest_features = dep.entry("features").or_insert(Item::None);
if let Some(features_array) = manifest_features.as_array_mut() {
// remove features that shouldn't be in the manifest anymore
let mut i = features_array.len();
let mut add_features = Vec::new();
while i != 0 {
let index = i - 1;
if let Some(f) = features_array.get(index).and_then(|f| f.as_str()) {
if remove.contains(&f) {
features_array.remove(index);
} else if let Some((_from, rename_to)) = rename.iter().find(|(from, _to)| *from == f) {
features_array.remove(index);
add_features.push(rename_to);
}
}
i -= 1;
}
for f in add_features {
features_array.push(f.to_string());
}
}
}
#[cfg(test)]
mod tests {
use itertools::Itertools;
fn migrate_deps<F: FnOnce(&[&str]) -> String>(get_toml: F) {
let keep_features = vec!["isolation", "protocol-asset"];
let mut features = super::features_to_remove();
features.extend(keep_features.clone());
let toml = get_toml(&features);
let mut manifest = toml
.parse::<toml_edit::DocumentMut>()
.expect("invalid toml");
super::migrate_manifest(&mut manifest).expect("failed to migrate manifest");
let dependencies = manifest
.as_table()
.get("dependencies")
.expect("missing manifest dependencies")
.as_table()
.expect("manifest dependencies isn't a table");
let tauri = dependencies
.get("tauri")
.expect("missing tauri dependency in manifest");
let tauri_table = if let Some(table) = tauri.as_table() {
table.clone()
} else if let Some(toml_edit::Value::InlineTable(table)) = tauri.as_value() {
table.clone().into_table()
} else if let Some(version) = tauri.as_str() {
// convert the value to a table for the assert logic below
let mut table = toml_edit::Table::new();
table.insert(
"version",
toml_edit::Item::Value(version.to_string().into()),
);
table.insert(
"features",
toml_edit::Item::Value(toml_edit::Value::Array(Default::default())),
);
table
} else {
panic!("unexpected tauri dependency format");
};
// assert version matches
let version = tauri_table
.get("version")
.expect("missing version")
.as_str()
.expect("version must be a string");
assert_eq!(version, super::dependency_version());
// assert features matches
let features = tauri_table
.get("features")
.expect("missing features")
.as_array()
.expect("features must be an array")
.clone();
if toml.contains("reqwest-native-tls-vendored") {
assert!(
features
.iter()
.any(|f| f.as_str().expect("feature must be a string") == "native-tls-vendored"),
"reqwest-native-tls-vendored was not replaced with native-tls-vendored"
);
}
if toml.contains("system-tray") {
assert!(
features
.iter()
.any(|f| f.as_str().expect("feature must be a string") == "tray-icon"),
"system-tray was not replaced with tray-icon"
);
}
for feature in features.iter() {
let feature = feature.as_str().expect("feature must be a string");
assert!(
keep_features.contains(&feature)
|| feature == "native-tls-vendored"
|| feature == "tray-icon",
"feature {feature} should have been removed"
);
}
}
#[test]
fn migrate_table() {
migrate_deps(|features| {
format!(
r#"
[dependencies]
tauri = {{ version = "1.0.0", features = [{}] }}
"#,
features.iter().map(|f| format!("{f:?}")).join(", ")
)
});
}
#[test]
fn migrate_inline_table() {
migrate_deps(|features| {
format!(
r#"
[dependencies.tauri]
version = "1.0.0"
features = [{}]
"#,
features.iter().map(|f| format!("{f:?}")).join(", ")
)
});
}
#[test]
fn migrate_str() {
migrate_deps(|_features| {
r#"
[dependencies]
tauri = "1.0.0"
"#
.into()
})
}
#[test]
fn migrate_add_crate_types() {
let toml = r#"
[lib]
crate-type = ["something"]"#;
let mut manifest = toml
.parse::<toml_edit::DocumentMut>()
.expect("invalid toml");
super::migrate_manifest(&mut manifest).expect("failed to migrate manifest");
if let Some(crate_types) = manifest
.as_table()
.get("lib")
.and_then(|l| l.get("crate-type"))
.and_then(|c| c.as_array())
{
let mut not_added_crate_types = super::CRATE_TYPES.to_vec();
for t in crate_types {
let t = t.as_str().expect("crate-type must be a string");
if let Some(i) = not_added_crate_types.iter().position(|ty| ty == &t) {
not_added_crate_types.remove(i);
}
}
assert!(
not_added_crate_types.is_empty(),
"missing crate-type: {not_added_crate_types:?}"
);
}
}
}

View File

@@ -0,0 +1,34 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::{Result, error::Context, helpers::app_paths::Dirs};
mod config;
mod frontend;
mod manifest;
pub fn run(dirs: &Dirs) -> Result<()> {
let mut migrated = config::migrate(dirs.tauri).context("Could not migrate config")?;
manifest::migrate(dirs.tauri).context("Could not migrate manifest")?;
let plugins = frontend::migrate(dirs.frontend)?;
migrated.plugins.extend(plugins);
// Add plugins
for plugin in migrated.plugins {
crate::add::run(
crate::add::Options {
plugin: plugin.clone(),
branch: None,
tag: None,
rev: None,
no_fmt: false,
},
dirs,
)
.with_context(|| format!("Could not migrate plugin '{plugin}'"))?;
}
Ok(())
}

View File

@@ -0,0 +1,194 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::{
Result,
error::{Context, ErrorExt},
helpers::{app_paths::Dirs, npm::PackageManager},
interface::rust::manifest::{read_manifest, serialize_manifest},
};
use std::{fs::read_to_string, path::Path};
use toml_edit::{DocumentMut, Item, Table, TableLike, Value};
pub fn run(dirs: &Dirs) -> Result<()> {
let manifest_path = dirs.tauri.join("Cargo.toml");
let (mut manifest, _) = read_manifest(&manifest_path)?;
migrate_manifest(&mut manifest)?;
migrate_permissions(dirs.tauri)?;
migrate_npm_dependencies(dirs.frontend)?;
std::fs::write(&manifest_path, serialize_manifest(&manifest))
.fs_context("failed to rewrite Cargo manifest", &manifest_path)?;
Ok(())
}
fn migrate_npm_dependencies(frontend_dir: &Path) -> Result<()> {
let pm = PackageManager::from_project(frontend_dir);
let mut install_deps = Vec::new();
for pkg in [
"@tauri-apps/cli",
"@tauri-apps/api",
"@tauri-apps/plugin-authenticator",
"@tauri-apps/plugin-autostart",
"@tauri-apps/plugin-barcode-scanner",
"@tauri-apps/plugin-biometric",
"@tauri-apps/plugin-cli",
"@tauri-apps/plugin-clipboard-manager",
"@tauri-apps/plugin-deep-link",
"@tauri-apps/plugin-dialog",
"@tauri-apps/plugin-fs",
"@tauri-apps/plugin-global-shortcut",
"@tauri-apps/plugin-http",
"@tauri-apps/plugin-log",
"@tauri-apps/plugin-nfc",
"@tauri-apps/plugin-notification",
"@tauri-apps/plugin-os",
"@tauri-apps/plugin-positioner",
"@tauri-apps/plugin-process",
"@tauri-apps/plugin-shell",
"@tauri-apps/plugin-sql",
"@tauri-apps/plugin-store",
"@tauri-apps/plugin-stronghold",
"@tauri-apps/plugin-updater",
"@tauri-apps/plugin-upload",
"@tauri-apps/plugin-websocket",
"@tauri-apps/plugin-window-state",
] {
let version = pm
.current_package_version(pkg, frontend_dir)
.unwrap_or_default()
.unwrap_or_default();
if version.starts_with('1') {
install_deps.push(format!("{pkg}@^2.0.0"));
}
}
if !install_deps.is_empty() {
pm.install(&install_deps, frontend_dir)?;
}
Ok(())
}
fn migrate_permissions(tauri_dir: &Path) -> Result<()> {
let core_plugins = [
"app",
"event",
"image",
"menu",
"path",
"resources",
"tray",
"webview",
"window",
];
for entry in walkdir::WalkDir::new(tauri_dir.join("capabilities")) {
let entry = entry.map_err(std::io::Error::other).fs_context(
"failed to walk capabilities directory",
tauri_dir.join("capabilities"),
)?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json") {
let mut capability =
read_to_string(path).fs_context("failed to read capability", path.to_path_buf())?;
for plugin in core_plugins {
capability = capability.replace(&format!("\"{plugin}:"), &format!("\"core:{plugin}:"));
}
std::fs::write(path, capability)
.fs_context("failed to rewrite capability", path.to_path_buf())?;
}
}
Ok(())
}
fn migrate_manifest(manifest: &mut DocumentMut) -> Result<()> {
let version = "2.0.0";
let dependencies = manifest
.as_table_mut()
.entry("dependencies")
.or_insert(Item::Table(Table::new()))
.as_table_mut()
.context("manifest dependencies isn't a table")?;
for dep in [
"tauri",
"tauri-plugin-authenticator",
"tauri-plugin-autostart",
"tauri-plugin-barcode-scanner",
"tauri-plugin-biometric",
"tauri-plugin-cli",
"tauri-plugin-clipboard-manager",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-global-shortcut",
"tauri-plugin-http",
"tauri-plugin-localhost",
"tauri-plugin-log",
"tauri-plugin-nfc",
"tauri-plugin-notification",
"tauri-plugin-os",
"tauri-plugin-persisted-scope",
"tauri-plugin-positioner",
"tauri-plugin-process",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-sql",
"tauri-plugin-store",
"tauri-plugin-stronghold",
"tauri-plugin-updater",
"tauri-plugin-upload",
"tauri-plugin-websocket",
"tauri-plugin-window-state",
] {
migrate_dependency(dependencies, dep, version);
}
let build_dependencies = manifest
.as_table_mut()
.entry("build-dependencies")
.or_insert(Item::Table(Table::new()))
.as_table_mut()
.context("manifest build-dependencies isn't a table")?;
migrate_dependency(build_dependencies, "tauri-build", version);
Ok(())
}
fn migrate_dependency(dependencies: &mut Table, name: &str, version: &str) {
let item = dependencies.entry(name).or_insert(Item::None);
// do not rewrite if dependency uses workspace inheritance
if item
.get("workspace")
.and_then(|v| v.as_bool())
.unwrap_or_default()
{
log::info!(
"`{name}` dependency has workspace inheritance enabled. The features array won't be automatically rewritten."
);
return;
}
if let Some(dep) = item.as_table_mut() {
migrate_dependency_table(dep, version);
} else if let Some(Value::InlineTable(table)) = item.as_value_mut() {
migrate_dependency_table(table, version);
} else if item.as_str().is_some() {
*item = Item::Value(version.into());
}
}
fn migrate_dependency_table<D: TableLike>(dep: &mut D, version: &str) {
*dep.entry("version").or_insert(Item::None) = Item::Value(version.into());
}

View File

@@ -0,0 +1,72 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::{
Result,
error::{Context, ErrorExt, bail},
helpers::cargo_manifest::{CargoLock, CargoManifest, crate_version},
interface::rust::get_workspace_dir,
};
use std::{fs::read_to_string, str::FromStr};
mod migrations;
pub fn command() -> Result<()> {
let dirs = crate::helpers::app_paths::resolve_dirs();
let manifest_contents = read_to_string(dirs.tauri.join("Cargo.toml")).fs_context(
"failed to read Cargo manifest",
dirs.tauri.join("Cargo.toml"),
)?;
let manifest = toml::from_str::<CargoManifest>(&manifest_contents).with_context(|| {
format!(
"failed to parse Cargo manifest {}",
dirs.tauri.join("Cargo.toml").display()
)
})?;
let workspace_dir = get_workspace_dir(dirs.tauri)?;
let lock_path = workspace_dir.join("Cargo.lock");
let lock = if lock_path.exists() {
let lockfile_contents =
read_to_string(&lock_path).fs_context("failed to read Cargo lockfile", &lock_path)?;
let lock = toml::from_str::<CargoLock>(&lockfile_contents)
.with_context(|| format!("failed to parse Cargo lockfile {}", lock_path.display()))?;
Some(lock)
} else {
None
};
let tauri_version = crate_version(dirs.tauri, Some(&manifest), lock.as_ref(), "tauri")
.version
.context("failed to get tauri version")?;
let tauri_version = semver::Version::from_str(&tauri_version)
.with_context(|| format!("failed to parse tauri version {tauri_version}"))?;
if tauri_version.major == 1 {
migrations::v1::run(&dirs).context("failed to migrate from v1")?;
} else if tauri_version.major == 2 {
if let Some((pre, _number)) = tauri_version.pre.as_str().split_once('.') {
match pre {
"beta" => {
migrations::v2_beta::run(&dirs).context("failed to migrate from v2 beta")?;
}
"alpha" => {
bail!(
"Migrating from v2 alpha ({tauri_version}) to v2 stable is not supported yet, \
if your project started early, try downgrading to v1 and then try again"
)
}
_ => {
bail!("Migrating from {tauri_version} to v2 stable is not supported yet")
}
}
} else {
log::info!("Nothing to do, the tauri version is already at v2 stable");
}
}
Ok(())
}

View File

@@ -0,0 +1,334 @@
// 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(&tauri_config);
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 {
if 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 {
if 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
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 String::from_utf8_lossy(&reverse_list_output.stdout).contains(&forward) {
// device matches our target, we can skip forwarding
if device.serial_no() == target_device_serial_no {
log::debug!(
"device {} already has the forward for {}",
device.name(),
forward
);
already_forwarded = true;
}
break;
}
}
// 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 {
loop {
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 String::from_utf8_lossy(&reverse_list_output.stdout).contains(&forward) {
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()
}

View File

@@ -0,0 +1,342 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use super::{
MobileTarget, OptionsHandle, configure_cargo, delete_codegen_vars, ensure_init, env, get_app,
get_config, inject_resources, log_finished, open_and_wait, sync_debug_application_id_suffix,
};
use crate::{
ConfigValue, Error, Result,
build::Options as BuildOptions,
error::Context,
helpers::{
app_paths::Dirs,
config::{ConfigMetadata, get_config as get_tauri_config},
flock,
},
interface::{AppInterface, Options as InterfaceOptions},
mobile::{CliOptions, TargetDevice, android::generate_tauri_properties, write_options},
};
use clap::{ArgAction, Parser};
use cargo_mobile2::{
android::{aab, apk, config::Config as AndroidConfig, env::Env, target::Target},
opts::{NoiseLevel, Profile},
target::TargetTrait,
};
use std::env::set_current_dir;
use std::path::Path;
#[derive(Debug, Clone, Parser)]
#[clap(
about = "Build your app in release mode for Android and generate APKs and AABs",
long_about = "Build your app in release mode for Android and generate APKs and AABs. It makes use of the `build.frontendDist` property from your `tauri.conf.json` file. It also runs your `build.beforeBuildCommand` which usually builds your frontend into `build.frontendDist`."
)]
pub struct Options {
/// Builds with the debug flag
#[clap(short, long)]
pub debug: bool,
/// Which targets to build (all by default).
#[clap(
short,
long = "target",
action = ArgAction::Append,
num_args(0..),
value_parser(clap::builder::PossibleValuesParser::new(Target::name_list()))
)]
pub targets: Option<Vec<String>>,
/// List of cargo features to activate
#[clap(short, long, action = ArgAction::Append, num_args(0..), value_delimiter = ',')]
pub features: Vec<String>,
/// 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>,
/// 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,
#[clap(skip)]
pub skip_bundle: bool,
/// Open Android Studio
#[clap(short, long)]
pub open: bool,
/// Skip prompting for values
#[clap(long, env = "CI")]
pub ci: bool,
/// Command line arguments passed to the runner.
/// Use `--` to explicitly mark the start of the arguments.
/// e.g. `tauri android build -- [runnerArgs]`.
#[clap(last(true))]
pub args: Vec<String>,
/// Do not error out if a version mismatch is detected on a Tauri package.
///
/// Only use this when you are sure the mismatch is incorrectly detected as version mismatched Tauri packages can lead to unknown behavior.
#[clap(long)]
pub ignore_version_mismatches: bool,
/// Target device of this build
#[clap(skip)]
pub target_device: Option<TargetDevice>,
}
impl From<Options> for BuildOptions {
fn from(options: Options) -> Self {
Self {
runner: None,
debug: options.debug,
target: None,
features: options.features,
bundles: None,
no_bundle: false,
config: options.config,
args: options.args,
ci: options.ci,
skip_stapling: false,
ignore_version_mismatches: options.ignore_version_mismatches,
no_sign: false,
no_binary_patching: false,
}
}
}
pub struct BuiltApplication {
pub config: AndroidConfig,
pub interface: AppInterface,
// prevent drop
#[allow(dead_code)]
options_handle: OptionsHandle,
}
pub fn command(options: Options, noise_level: NoiseLevel) -> Result<BuiltApplication> {
let dirs = crate::helpers::app_paths::resolve_dirs();
let tauri_config = get_tauri_config(
tauri_utils::platform::Target::Android,
&options
.config
.iter()
.map(|conf| &conf.0)
.collect::<Vec<_>>(),
dirs.tauri,
)?;
run(options, noise_level, &dirs, &tauri_config)
}
pub fn run(
options: Options,
noise_level: NoiseLevel,
dirs: &Dirs,
tauri_config: &ConfigMetadata,
) -> Result<BuiltApplication> {
delete_codegen_vars();
let mut build_options: BuildOptions = options.clone().into();
let first_target = Target::all()
.get(
options
.targets
.as_ref()
.and_then(|l| l.first().map(|t| t.as_str()))
.unwrap_or(Target::DEFAULT_KEY),
)
.unwrap();
build_options.target = Some(first_target.triple.into());
let interface = AppInterface::new(tauri_config, build_options.target.clone(), dirs.tauri)?;
interface.build_options(&mut build_options.args, &mut build_options.features, true);
let app = get_app(MobileTarget::Android, tauri_config, &interface, dirs.tauri);
let (config, metadata) = get_config(
&app,
tauri_config,
&build_options.features,
&CliOptions {
dev: false,
features: build_options.features.clone(),
args: build_options.args.clone(),
noise_level,
vars: Default::default(),
config: build_options.config.clone(),
target_device: None,
},
);
let profile = if options.debug {
Profile::Debug
} else {
Profile::Release
};
set_current_dir(dirs.tauri).context("failed to set current directory to Tauri directory")?;
ensure_init(
tauri_config,
config.app(),
config.project_dir(),
MobileTarget::Android,
options.ci,
)?;
let mut env = env(options.ci)?;
configure_cargo(&mut env, &config)?;
generate_tauri_properties(&config, tauri_config, false)?;
sync_debug_application_id_suffix(&config, tauri_config)?;
crate::build::setup(&interface, &mut build_options, tauri_config, dirs, true)?;
let installed_targets =
crate::interface::rust::installation::installed_targets().unwrap_or_default();
if !installed_targets.contains(&first_target.triple().into()) {
log::info!("Installing target {}", first_target.triple());
first_target
.install()
.map_err(|error| Error::CommandFailed {
command: "rustup target add".to_string(),
error,
})
.context("failed to install target")?;
}
// run an initial build to initialize plugins
first_target
.build(&config, &metadata, &env, noise_level, true, profile)
.context("failed to build Android app")?;
let open = options.open;
let options_handle = run_build(
&interface,
options,
build_options,
tauri_config,
profile,
&config,
&mut env,
noise_level,
dirs.tauri,
)?;
if open {
open_and_wait(&config, &env);
}
Ok(BuiltApplication {
config,
interface,
options_handle,
})
}
#[allow(clippy::too_many_arguments)]
fn run_build(
interface: &AppInterface,
mut options: Options,
build_options: BuildOptions,
tauri_config: &ConfigMetadata,
profile: Profile,
config: &AndroidConfig,
env: &mut Env,
noise_level: NoiseLevel,
tauri_dir: &Path,
) -> Result<OptionsHandle> {
if !(options.skip_bundle || 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 interface_options = InterfaceOptions {
debug: build_options.debug,
target: build_options.target.clone(),
args: build_options.args.clone(),
..Default::default()
};
let app_settings = interface.app_settings();
let out_dir = app_settings.out_dir(&interface_options, tauri_dir)?;
let _lock = flock::open_rw(out_dir.join("lock").with_extension("android"), "Android")?;
let cli_options = CliOptions {
dev: false,
features: build_options.features.clone(),
args: build_options.args.clone(),
noise_level,
vars: Default::default(),
config: build_options.config,
target_device: options.target_device.clone(),
};
let handle = write_options(tauri_config, cli_options)?;
inject_resources(config, tauri_config)?;
let apk_outputs = if options.apk {
apk::build(
config,
env,
noise_level,
profile,
get_targets_or_all(options.targets.clone().unwrap_or_default())?,
options.split_per_abi,
)
.context("failed to build APK")?
} else {
Vec::new()
};
let aab_outputs = if options.aab {
aab::build(
config,
env,
noise_level,
profile,
get_targets_or_all(options.targets.unwrap_or_default())?,
options.split_per_abi,
)
.context("failed to build AAB")?
} else {
Vec::new()
};
if !apk_outputs.is_empty() {
log_finished(apk_outputs, "APK");
}
if !aab_outputs.is_empty() {
log_finished(aab_outputs, "AAB");
}
Ok(handle)
}
fn get_targets_or_all<'a>(targets: Vec<String>) -> Result<Vec<&'a Target<'a>>> {
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).with_context(|| {
format!("Target {t} is invalid; the possible targets are {possible_targets}",)
})?;
outs.push(target);
}
Ok(outs)
}
}

View File

@@ -0,0 +1,156 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! Java detection and Java/Gradle compatibility checks for Android builds.
use std::path::{Path, PathBuf};
pub fn check_java_gradle_versions() -> Option<()> {
let java_major_version = java_major_version()?;
let project_gradle_version = project_gradle_version()?;
let gradle_max_supported_java = gradle_max_supported_java(&project_gradle_version)?;
// Read the Gradle version this project actually uses (the user may have upgraded it away from
// the template default) and only warn when the detected Java is too new for that Gradle.
if java_major_version > gradle_max_supported_java {
log::warn!(
"Detected Java {java_major_version}, but Gradle {project_gradle_version} used by this project can only run on Java up to {gradle_max_supported_java}. \
Android builds will likely fail with a cryptic error. Install a JDK that Gradle {project_gradle_version} supports \
(Java {gradle_max_supported_java} or older) and point JAVA_HOME at it. \
See https://docs.gradle.org/current/userguide/compatibility.html"
);
}
Some(())
}
/// Reads the Gradle version this project is pinned to from its
/// `gradle/wrapper/gradle-wrapper.properties` `distributionUrl` (e.g.
/// `gradle-8.14-bin.zip` -> `8.14`). Returns `None` when the project isn't
/// generated yet or the file can't be parsed, in which case no warning is shown.
fn project_gradle_version() -> Option<String> {
let project_path = std::env::var_os("TAURI_ANDROID_PROJECT_PATH")?;
let properties = std::fs::read_to_string(
Path::new(&project_path).join("gradle/wrapper/gradle-wrapper.properties"),
)
.ok()?;
properties.lines().find_map(|line| {
let url = line.trim().strip_prefix("distributionUrl=")?;
let file = url.rsplit('/').next()?;
let version = file.strip_prefix("gradle-")?.split('-').next()?;
(!version.is_empty()).then(|| version.to_string())
})
}
/// Highest Java feature version a given Gradle release can *run* on.
/// Known Gradle 7.x through 9.x releases are mapped. Unknown future major
/// versions return `None` to avoid false warnings. See
/// <https://docs.gradle.org/current/userguide/compatibility.html>.
fn gradle_max_supported_java(version: &str) -> Option<u32> {
let mut parts = version.split('.');
let major: u32 = parts.next()?.parse().ok()?;
let minor: u32 = parts.next().and_then(|m| m.parse().ok()).unwrap_or(0);
if major >= 10 {
return None;
}
Some(match (major, minor) {
v if v >= (9, 4) => 26,
v if v >= (9, 1) => 25,
v if v >= (8, 14) => 24,
v if v >= (8, 10) => 23,
v if v >= (8, 8) => 22,
v if v >= (8, 5) => 21,
v if v >= (8, 3) => 20,
v if v >= (7, 6) => 19,
v if v >= (7, 5) => 18,
v if v >= (7, 3) => 17,
_ => return None,
})
}
/// Detects the major (feature) version of the active Java installation by
/// reading the `release` metadata file shipped inside every JDK, preferring
/// `JAVA_HOME` and falling back to the `java` binary on `PATH`. This avoids
/// spawning a `java -version` subprocess on every Android command.
fn java_major_version() -> Option<u32> {
let release = std::fs::read_to_string(java_home_dir()?.join("release")).ok()?;
release
.lines()
.find_map(|line| line.strip_prefix("JAVA_VERSION="))
.and_then(parse_java_major)
}
/// Resolves the active JDK home directory, preferring `JAVA_HOME` and otherwise
/// deriving it from the `java` binary on `PATH` (`<home>/bin/java` -> `<home>`).
fn java_home_dir() -> Option<PathBuf> {
if let Some(home) = std::env::var_os("JAVA_HOME") {
return Some(PathBuf::from(home));
}
let java = which::which("java").ok()?;
java.parent()?.parent().map(Path::to_path_buf)
}
/// Parses the major (feature) version out of a quoted Java version string (the
/// `JAVA_VERSION="..."` line of a JDK `release` file, or a `java -version`
/// banner), handling both the legacy `1.x` scheme (`1.8.0_292` -> 8) and the
/// modern scheme (`21.0.1` -> 21).
fn parse_java_major(version_output: &str) -> Option<u32> {
let start = version_output.find('"')?;
let rest = &version_output[start + 1..];
let end = rest.find('"')?;
let mut parts = rest[..end].split(['.', '_', '-']);
let first = parts.next()?;
if first == "1" {
parts.next()?.parse().ok()
} else {
first.parse().ok()
}
}
#[cfg(test)]
mod tests {
use super::{gradle_max_supported_java, parse_java_major};
#[test]
fn parses_java_major_version() {
// modern scheme
assert_eq!(
parse_java_major("openjdk version \"21.0.1\" 2023-10-17"),
Some(21)
);
assert_eq!(
parse_java_major("openjdk version \"26\" 2026-03-17"),
Some(26)
);
assert_eq!(
parse_java_major("java version \"17.0.9\" 2023-10-17 LTS"),
Some(17)
);
// legacy 1.x scheme
assert_eq!(parse_java_major("java version \"1.8.0_292\""), Some(8));
// JDK `release` file line (the path java_major_version actually feeds in)
assert_eq!(parse_java_major("\"21.0.1\""), Some(21));
// garbage
assert_eq!(parse_java_major("no version here"), None);
}
#[test]
fn maps_gradle_to_max_java() {
assert_eq!(gradle_max_supported_java("9.6.1"), Some(26));
assert_eq!(gradle_max_supported_java("9.4.0"), Some(26));
assert_eq!(gradle_max_supported_java("9.3"), Some(25));
assert_eq!(gradle_max_supported_java("9.1.0"), Some(25));
assert_eq!(gradle_max_supported_java("9.0"), Some(24));
assert_eq!(gradle_max_supported_java("8.14"), Some(24));
assert_eq!(gradle_max_supported_java("8.5"), Some(21));
assert_eq!(gradle_max_supported_java("7.3"), Some(17));
// pre-7.3 is unmapped
assert_eq!(gradle_max_supported_java("7.2"), None);
// Unknown future Gradle majors are left unmapped to avoid false warnings.
assert_eq!(gradle_max_supported_java("10.0"), None);
// unparsable
assert_eq!(gradle_max_supported_java("not-a-version"), None);
}
}

View File

@@ -0,0 +1,413 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use super::{
MobileTarget, configure_cargo, delete_codegen_vars, device_prompt, ensure_init, env, get_app,
get_config, inject_resources, open_and_wait, sync_debug_application_id_suffix,
};
use crate::{
ConfigValue, Error, Result,
dev::Options as DevOptions,
error::{Context, ErrorExt},
helpers::{
app_paths::Dirs,
config::{ConfigMetadata, get_config as get_tauri_config},
flock,
},
interface::{AppInterface, MobileOptions, Options as InterfaceOptions},
mobile::{
CliOptions, DevChild, DevHost, DevProcess, TargetDevice, android::generate_tauri_properties,
use_network_address_for_dev_url, write_options,
},
};
use clap::{ArgAction, Parser};
use cargo_mobile2::{
android::{
config::{Config as AndroidConfig, Metadata as AndroidMetadata},
device::Device,
env::Env,
target::Target,
},
opts::{FilterLevel, NoiseLevel, Profile},
target::TargetTrait,
};
use url::Host;
use std::{env::set_current_dir, net::Ipv4Addr, path::PathBuf};
#[derive(Debug, Clone, Parser)]
#[clap(
about = "Run your app in development mode on Android",
long_about = "Run your app in development mode on Android 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."
)]
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 Android Studio 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.
///
/// On Windows we use the public network address by default.
///
/// 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 android 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,
args: options.args,
no_watch: options.no_watch,
additional_watch_folders: options.additional_watch_folders,
no_dev_server_wait: options.no_dev_server_wait,
no_dev_server: options.no_dev_server,
port: options.port,
release_mode: options.release_mode,
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<()> {
delete_codegen_vars();
// setup env additions before calling env()
if let Some(root_certificate_path) = &options.root_certificate_path {
unsafe {
std::env::set_var(
"TAURI_DEV_ROOT_CERTIFICATE",
std::fs::read_to_string(root_certificate_path).fs_context(
"failed to read certificate file",
root_certificate_path.clone(),
)?,
)
};
}
let tauri_config = get_tauri_config(
tauri_utils::platform::Target::Android,
&options
.config
.iter()
.map(|conf| &conf.0)
.collect::<Vec<_>>(),
dirs.tauri,
)?;
let env = env(false)?;
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
}
}
};
let mut dev_options: DevOptions = options.clone().into();
let target_triple = device
.as_ref()
.map(|d| d.target().triple.to_string())
.unwrap_or_else(|| Target::all().values().next().unwrap().triple.into());
dev_options.target = Some(target_triple);
dev_options.args.push("--lib".into());
let interface = AppInterface::new(&tauri_config, dev_options.target.clone(), dirs.tauri)?;
let app = get_app(MobileTarget::Android, &tauri_config, &interface, dirs.tauri);
let (config, metadata) = get_config(
&app,
&tauri_config,
dev_options.features.as_ref(),
&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,
},
);
set_current_dir(dirs.tauri).context("failed to set current directory to Tauri directory")?;
ensure_init(
&tauri_config,
config.app(),
config.project_dir(),
MobileTarget::Android,
false,
)?;
run_dev(
interface,
options,
dev_options,
tauri_config,
device,
env,
&config,
&metadata,
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>,
mut env: Env,
config: &AndroidConfig,
metadata: &AndroidMetadata,
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| !device.serial_no().starts_with("emulator"))
.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 interface_options = InterfaceOptions {
debug: !dev_options.release_mode,
target: dev_options.target.clone(),
..Default::default()
};
let app_settings = interface.app_settings();
let out_dir = app_settings.out_dir(&interface_options, dirs.tauri)?;
let _lock = flock::open_rw(out_dir.join("lock").with_extension("android"), "Android")?;
configure_cargo(&mut env, config)?;
generate_tauri_properties(config, &tauri_config, true)?;
sync_debug_application_id_suffix(config, &tauri_config)?;
let installed_targets =
crate::interface::rust::installation::installed_targets().unwrap_or_default();
// run an initial build to initialize plugins
let target_triple = dev_options.target.as_ref().unwrap();
let target = Target::all()
.values()
.find(|t| t.triple == target_triple)
.unwrap_or_else(|| Target::all().values().next().unwrap());
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,
})?;
}
target
.build(
config,
metadata,
&env,
noise_level,
true,
if options.release_mode {
Profile::Release
} else {
Profile::Debug
},
)
.context("failed to build Android app")?;
let open = options.open;
interface.mobile_dev(
&mut tauri_config,
MobileOptions {
debug: !options.release_mode,
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: device.as_ref().map(|d| TargetDevice {
id: d.serial_no().to_string(),
name: d.name().to_string(),
}),
};
let _handle = write_options(tauri_config, cli_options)?;
inject_resources(config, tauri_config)?;
if open {
open_and_wait(config, &env)
} else if let Some(device) = &device {
match run(
device,
options,
config,
&env,
metadata,
noise_level,
tauri_config,
) {
Ok(c) => Ok(Box::new(c) as Box<dyn DevProcess + Send>),
Err(e) => {
crate::dev::kill_before_dev_process();
Err(e)
}
}
} else {
open_and_wait(config, &env)
}
},
dirs,
)
}
fn run(
device: &Device<'_>,
options: MobileOptions,
config: &AndroidConfig,
env: &Env,
metadata: &AndroidMetadata,
noise_level: NoiseLevel,
tauri_config: &tauri_utils::config::Config,
) -> crate::Result<DevChild> {
let profile = if options.debug {
Profile::Debug
} else {
Profile::Release
};
let build_app_bundle = metadata.asset_packs().is_some();
let application_id_suffix = if profile == Profile::Debug {
tauri_config
.bundle
.android
.debug_application_id_suffix
.clone()
} else {
None
};
device
.run_with_application_id_suffix(
config,
env,
noise_level,
profile,
Some(match noise_level {
NoiseLevel::Polite => FilterLevel::Info,
NoiseLevel::LoudAndProud => FilterLevel::Debug,
NoiseLevel::FranklyQuitePedantic => FilterLevel::Verbose,
}),
build_app_bundle,
false,
format!("{}.MainActivity", config.app().identifier()),
application_id_suffix,
)
.map(DevChild::new)
.context("failed to run Android app")
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,209 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::{
Error, Result,
error::{Context, ErrorExt},
helpers::template,
};
use cargo_mobile2::{
android::{
config::{Config, Metadata},
target::Target,
},
config::app::DEFAULT_ASSET_DIR,
os,
target::TargetTrait as _,
util::{
self,
cli::{Report, TextWrapper},
prefix_path,
},
};
use handlebars::Handlebars;
use include_dir::{Dir, include_dir};
use std::{
ffi::OsStr,
fs,
path::{Path, PathBuf},
};
const TEMPLATE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/templates/mobile/android");
pub fn generate(
config: &Config,
metadata: &Metadata,
(handlebars, mut map): (Handlebars, template::JsonMap),
wrapper: &TextWrapper,
skip_targets_install: bool,
) -> Result<()> {
if !skip_targets_install {
let installed_targets =
crate::interface::rust::installation::installed_targets().unwrap_or_default();
let missing_targets = Target::all()
.values()
.filter(|t| !installed_targets.contains(&t.triple().into()))
.collect::<Vec<&Target>>();
if !missing_targets.is_empty() {
log::info!("Installing Android Rust targets...");
for target in missing_targets {
log::info!("Installing target {}", target.triple());
target.install().map_err(|error| Error::CommandFailed {
command: "rustup target add".to_string(),
error,
})?;
}
}
}
println!("Generating Android Studio project...");
let dest = config.project_dir();
let asset_packs = metadata.asset_packs().unwrap_or_default();
map.insert(
"root-dir-rel",
Path::new(&os::replace_path_separator(
util::relativize_path(
config.app().root_dir(),
config.project_dir().join(config.app().name_snake()),
)
.into_os_string(),
)),
);
map.insert("root-dir", config.app().root_dir());
map.insert(
"abi-list",
Target::all()
.values()
.map(|target| target.abi)
.collect::<Vec<_>>(),
);
map.insert("target-list", Target::all().keys().collect::<Vec<_>>());
map.insert(
"arch-list",
Target::all()
.values()
.map(|target| target.arch)
.collect::<Vec<_>>(),
);
map.insert("android-app-plugins", metadata.app_plugins());
map.insert(
"android-project-dependencies",
metadata.project_dependencies(),
);
map.insert("android-app-dependencies", metadata.app_dependencies());
map.insert(
"android-app-dependencies-platform",
metadata.app_dependencies_platform(),
);
map.insert(
"has-code",
metadata.project_dependencies().is_some()
|| metadata.app_dependencies().is_some()
|| metadata.app_dependencies_platform().is_some(),
);
map.insert("has-asset-packs", !asset_packs.is_empty());
map.insert(
"asset-packs",
asset_packs
.iter()
.map(|p| p.name.as_str())
.collect::<Vec<_>>(),
);
map.insert("windows", cfg!(windows));
let identifier = config.app().identifier().replace('.', "/");
let package_path = format!("java/{identifier}");
map.insert("package-path", &package_path);
let mut created_dirs = Vec::new();
template::render_with_generator(
&handlebars,
map.inner(),
&TEMPLATE_DIR,
&dest,
&mut |path| generate_out_file(&path, &dest, &package_path, &mut created_dirs),
)
.with_context(|| "failed to process template")?;
if !asset_packs.is_empty() {
Report::action_request(
"When running from Android Studio, you must first set your deployment option to \"APK from app bundle\".",
"Android Studio will not be able to find your asset packs otherwise. The option can be found under \"Run > Edit Configurations > Deploy\"."
).print(wrapper);
}
let source_dest = dest.join("app");
for source in metadata.app_sources() {
let source_src = config.app().root_dir().join(source);
let source_file = source_src
.file_name()
.with_context(|| format!("asset source {} is invalid", source_src.display()))?;
fs::copy(&source_src, source_dest.join(source_file))
.fs_context("failed to copy asset", source_src)?;
}
let dest = prefix_path(dest, "app/src/main/");
fs::create_dir_all(&dest).fs_context("failed to create directory", dest.clone())?;
let asset_dir = dest.join(DEFAULT_ASSET_DIR);
if !asset_dir.is_dir() {
fs::create_dir_all(&asset_dir).fs_context("failed to create asset dir", asset_dir)?;
}
Ok(())
}
fn generate_out_file(
path: &Path,
dest: &Path,
package_path: &str,
created_dirs: &mut Vec<PathBuf>,
) -> std::io::Result<Option<fs::File>> {
let mut iter = path.iter();
let root = iter.next().unwrap().to_str().unwrap();
let path_without_root: std::path::PathBuf = iter.collect();
let path = match (
root,
path.extension().and_then(|o| o.to_str()),
path_without_root.strip_prefix("src/main"),
) {
("app" | "buildSrc", Some("kt"), Ok(path)) => {
let parent = path.parent().unwrap();
let file_name = path.file_name().unwrap();
let out_dir = dest
.join(root)
.join("src/main")
.join(package_path)
.join(parent);
out_dir.join(file_name)
}
_ => dest.join(path),
};
let parent = path.parent().unwrap().to_path_buf();
if !created_dirs.contains(&parent) {
fs::create_dir_all(&parent)?;
created_dirs.push(parent);
}
let mut options = fs::OpenOptions::new();
options.write(true);
#[cfg(unix)]
if path.file_name().unwrap() == OsStr::new("gradlew") {
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o755);
}
if path.file_name().unwrap() == OsStr::new("BuildTask.kt") {
options.truncate(true).create(true).open(path).map(Some)
} else if !path.exists() {
options.create(true).open(path).map(Some)
} else {
Ok(None)
}
}

View File

@@ -0,0 +1,184 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use cargo_mobile2::{
android::target::Target,
opts::{FilterLevel, NoiseLevel, Profile},
target::TargetTrait,
};
use clap::{ArgAction, Parser};
use std::path::PathBuf;
use super::{configure_cargo, device_prompt, env, sync_debug_application_id_suffix};
use crate::{
ConfigValue, Result,
error::Context,
helpers::config::ConfigMetadata,
interface::{DevProcess, WatcherOptions},
mobile::{DevChild, TargetDevice},
};
#[derive(Debug, Clone, Parser)]
#[clap(
about = "Run your app in production mode on Android",
long_about = "Run your app in production mode on Android. It makes use of the `build.frontendDist` property from your `tauri.conf.json` file. It also runs your `build.beforeBuildCommand` which usually builds your frontend into `build.frontendDist`."
)]
pub struct Options {
/// Run the app in release mode
#[clap(short, long)]
pub release: bool,
/// List of cargo features to activate
#[clap(short, long, action = ArgAction::Append, num_args(0..), value_delimiter = ',')]
pub features: Vec<String>,
/// 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>,
/// 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 Android Studio
#[clap(short, long)]
pub open: bool,
/// Runs on the given device name
pub device: Option<String>,
/// Command line arguments passed to the runner.
/// Use `--` to explicitly mark the start of the arguments.
/// e.g. `tauri android build -- [runnerArgs]`.
#[clap(last(true))]
pub args: Vec<String>,
/// Do not error out if a version mismatch is detected on a Tauri package.
///
/// Only use this when you are sure the mismatch is incorrectly detected as version mismatched Tauri packages can lead to unknown behavior.
#[clap(long)]
pub ignore_version_mismatches: bool,
}
pub fn command(options: Options, noise_level: NoiseLevel) -> Result<()> {
let mut env = env(false)?;
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
}
}
};
let dirs = crate::helpers::app_paths::resolve_dirs();
let mut tauri_config = crate::helpers::config::get_config(
tauri_utils::platform::Target::Android,
&options
.config
.iter()
.map(|conf| &conf.0)
.collect::<Vec<_>>(),
dirs.tauri,
)?;
let mut built_application = super::build::run(
super::build::Options {
debug: !options.release,
targets: device.as_ref().map(|d| {
vec![
Target::all()
.iter()
.find(|(_key, t)| t.arch == d.target().arch)
.map(|(key, _t)| key.to_string())
.expect("Target not found"),
]
}),
features: options.features,
config: options.config.clone(),
split_per_abi: true,
apk: false,
aab: false,
skip_bundle: true,
open: options.open,
ci: false,
args: options.args,
ignore_version_mismatches: options.ignore_version_mismatches,
target_device: device.as_ref().map(|d| TargetDevice {
id: d.serial_no().to_string(),
name: d.name().to_string(),
}),
},
noise_level,
&dirs,
&tauri_config,
)?;
configure_cargo(&mut env, &built_application.config)?;
// options.open is handled by the build command
// so all we need to do here is run the app on the selected device
if let Some(device) = device {
let config = built_application.config.clone();
let release = options.release;
let runner = move |tauri_config: &ConfigMetadata| {
sync_debug_application_id_suffix(&config, tauri_config)?;
let application_id_suffix = if !release {
tauri_config
.bundle
.android
.debug_application_id_suffix
.clone()
} else {
None
};
device
.run_with_application_id_suffix(
&config,
&env,
noise_level,
if !release {
Profile::Debug
} else {
Profile::Release
},
Some(match noise_level {
NoiseLevel::Polite => FilterLevel::Info,
NoiseLevel::LoudAndProud => FilterLevel::Debug,
NoiseLevel::FranklyQuitePedantic => FilterLevel::Verbose,
}),
false,
false,
format!("{}.MainActivity", config.app().identifier()),
application_id_suffix,
)
.map(|c| Box::new(DevChild::new(c)) as Box<dyn DevProcess + Send>)
.context("failed to run Android app")
};
if options.no_watch {
runner(&tauri_config)?;
} else {
built_application.interface.watch(
&mut tauri_config,
WatcherOptions {
config: options.config,
additional_watch_folders: options.additional_watch_folders,
},
runner,
&dirs,
)?;
}
}
Ok(())
}

View File

@@ -0,0 +1,414 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use super::{Target, get_app};
use crate::{
ConfigValue, Result,
helpers::app_paths::Dirs,
helpers::{config::get_config as get_tauri_config, template::JsonMap},
interface::AppInterface,
};
use cargo_mobile2::{
config::app::App,
reserved_names::KOTLIN_ONLY_KEYWORDS,
util::{
self,
cli::{Report, TextWrapper},
},
};
use handlebars::{
Context, Handlebars, Helper, HelperResult, Output, RenderContext, RenderError, RenderErrorReason,
};
use std::{env::var_os, path::PathBuf};
pub fn command(
target: Target,
ci: bool,
reinstall_deps: bool,
skip_targets_install: bool,
config: Vec<ConfigValue>,
) -> Result<()> {
let dirs = crate::helpers::app_paths::resolve_dirs();
let wrapper = TextWrapper::default();
exec(
target,
&wrapper,
ci,
reinstall_deps,
skip_targets_install,
config,
dirs,
)?;
Ok(())
}
fn exec(
target: Target,
wrapper: &TextWrapper,
#[allow(unused_variables)] non_interactive: bool,
#[allow(unused_variables)] reinstall_deps: bool,
skip_targets_install: bool,
config: Vec<ConfigValue>,
dirs: Dirs,
) -> Result<App> {
let tauri_config = get_tauri_config(
target.platform_target(),
&config.iter().map(|conf| &conf.0).collect::<Vec<_>>(),
dirs.tauri,
)?;
let app = get_app(
target,
&tauri_config,
&AppInterface::new(&tauri_config, None, dirs.tauri)?,
dirs.tauri,
);
let (handlebars, mut map) = handlebars(&app);
let mut args = std::env::args_os();
let (binary, mut build_args) = args
.next()
.map(|bin| {
let bin_path = PathBuf::from(&bin);
let mut build_args = vec!["tauri"];
if let Some(bin_stem) = bin_path.file_stem() {
let r = regex::Regex::new("(nodejs|node)\\-?([1-9]*)*$").unwrap();
if r.is_match(&bin_stem.to_string_lossy()) {
if var_os("PNPM_PACKAGE_NAME").is_some() {
return ("pnpm".into(), build_args);
} else if is_pnpm_dlx() {
return ("pnpm".into(), vec!["dlx", "@tauri-apps/cli"]);
} else if is_pnpm_run() {
return ("pnpm".into(), build_args);
} else if let Some(npm_execpath) = var_os("npm_execpath") {
let manager_stem = PathBuf::from(&npm_execpath)
.file_stem()
.unwrap()
.to_os_string();
let is_npm = manager_stem == "npm-cli";
let binary = if is_npm {
"npm".into()
} else if manager_stem == "npx-cli" {
"npx".into()
} else {
manager_stem
};
if is_npm {
build_args.insert(0, "run");
build_args.insert(1, "--");
}
return (binary, build_args);
}
} else if bin_stem == "deno" {
build_args.insert(0, "task");
return (std::ffi::OsString::from("deno"), build_args);
} else if !cfg!(debug_assertions) && bin_stem == "cargo-tauri" {
return (std::ffi::OsString::from("cargo"), build_args);
}
}
(bin, build_args)
})
.unwrap_or_else(|| (std::ffi::OsString::from("cargo"), vec!["tauri"]));
build_args.push(target.command_name());
build_args.push(target.ide_build_script_name());
let mut binary = binary.to_string_lossy().to_string();
if binary.ends_with(".exe") || binary.ends_with(".cmd") || binary.ends_with(".bat") {
// remove Windows-only extension
binary.pop();
binary.pop();
binary.pop();
binary.pop();
}
map.insert("tauri-binary", binary);
map.insert("tauri-binary-args", &build_args);
map.insert("tauri-binary-args-str", build_args.join(" "));
let app = match target {
// Generate Android Studio project
Target::Android => {
let _env = super::android::env(non_interactive)?;
let (config, metadata) =
super::android::get_config(&app, &tauri_config, &[], &Default::default());
map.insert("android", &config);
// Add application_id_suffix to the map for template access
// The template will access it via a helper or we'll modify template to use root context
if let Some(suffix) = &tauri_config.bundle.android.debug_application_id_suffix {
map.insert("android-debug-application-id-suffix", suffix);
}
super::android::project::generate(
&config,
&metadata,
(handlebars, map),
wrapper,
skip_targets_install,
)?;
app
}
#[cfg(target_os = "macos")]
// Generate Xcode project
Target::Ios => {
let (config, metadata) =
super::ios::get_config(&app, &tauri_config, &[], &Default::default(), dirs.tauri)?;
map.insert("apple", &config);
super::ios::project::generate(
&tauri_config,
&config,
&metadata,
(handlebars, map),
wrapper,
non_interactive,
reinstall_deps,
skip_targets_install,
)?;
app
}
};
Report::victory(
"Project generated successfully!",
"Make cool apps! 🌻 🐕 🎉",
)
.print(wrapper);
Ok(app)
}
fn handlebars(app: &App) -> (Handlebars<'static>, JsonMap) {
let mut h = Handlebars::new();
h.register_escape_fn(handlebars::no_escape);
h.register_helper("html-escape", Box::new(html_escape));
h.register_helper("join", Box::new(join));
h.register_helper("quote-and-join", Box::new(quote_and_join));
h.register_helper(
"quote-and-join-colon-prefix",
Box::new(quote_and_join_colon_prefix),
);
h.register_helper("snake-case", Box::new(snake_case));
h.register_helper("escape-kotlin-keyword", Box::new(escape_kotlin_keyword));
// don't mix these up or very bad things will happen to all of us
h.register_helper("prefix-path", Box::new(prefix_path));
h.register_helper("unprefix-path", Box::new(unprefix_path));
let mut map = JsonMap::default();
map.insert("app", app);
(h, map)
}
fn get_str<'a>(helper: &'a Helper) -> &'a str {
helper
.param(0)
.and_then(|v| v.value().as_str())
.unwrap_or("")
}
fn get_str_array(helper: &Helper, formatter: impl Fn(&str) -> String) -> Option<Vec<String>> {
helper.param(0).and_then(|v| {
v.value()
.as_array()
.and_then(|arr| arr.iter().map(|val| val.as_str().map(&formatter)).collect())
})
}
fn html_escape(
helper: &Helper,
_: &Handlebars,
_ctx: &Context,
_: &mut RenderContext,
out: &mut dyn Output,
) -> HelperResult {
out
.write(&handlebars::html_escape(get_str(helper)))
.map_err(Into::into)
}
fn join(
helper: &Helper,
_: &Handlebars,
_: &Context,
_: &mut RenderContext,
out: &mut dyn Output,
) -> HelperResult {
out
.write(
&get_str_array(helper, |s| s.to_string())
.ok_or_else(|| {
RenderErrorReason::ParamTypeMismatchForName("join", "0".to_owned(), "array".to_owned())
})?
.join(", "),
)
.map_err(Into::into)
}
fn quote_and_join(
helper: &Helper,
_: &Handlebars,
_: &Context,
_: &mut RenderContext,
out: &mut dyn Output,
) -> HelperResult {
out
.write(
&get_str_array(helper, |s| format!("{s:?}"))
.ok_or_else(|| {
RenderErrorReason::ParamTypeMismatchForName(
"quote-and-join",
"0".to_owned(),
"array".to_owned(),
)
})?
.join(", "),
)
.map_err(Into::into)
}
fn quote_and_join_colon_prefix(
helper: &Helper,
_: &Handlebars,
_: &Context,
_: &mut RenderContext,
out: &mut dyn Output,
) -> HelperResult {
out
.write(
&get_str_array(helper, |s| format!("{:?}", format!(":{s}")))
.ok_or_else(|| {
RenderErrorReason::ParamTypeMismatchForName(
"quote-and-join-colon-prefix",
"0".to_owned(),
"array".to_owned(),
)
})?
.join(", "),
)
.map_err(Into::into)
}
fn snake_case(
helper: &Helper,
_: &Handlebars,
_: &Context,
_: &mut RenderContext,
out: &mut dyn Output,
) -> HelperResult {
use heck::ToSnekCase as _;
out
.write(&get_str(helper).to_snek_case())
.map_err(Into::into)
}
fn escape_kotlin_keyword(
helper: &Helper,
_: &Handlebars,
_: &Context,
_: &mut RenderContext,
out: &mut dyn Output,
) -> HelperResult {
let escaped_result = get_str(helper)
.split('.')
.map(|s| {
if KOTLIN_ONLY_KEYWORDS.contains(&s) {
format!("`{s}`")
} else {
s.to_string()
}
})
.collect::<Vec<_>>()
.join(".");
out.write(&escaped_result).map_err(Into::into)
}
fn app_root(ctx: &Context) -> std::result::Result<&str, RenderError> {
let app_root = ctx
.data()
.get("app")
.ok_or_else(|| RenderErrorReason::Other("`app` missing from template data.".to_owned()))?
.get("root-dir")
.ok_or_else(|| {
RenderErrorReason::Other("`app.root-dir` missing from template data.".to_owned())
})?;
app_root.as_str().ok_or_else(|| {
RenderErrorReason::Other("`app.root-dir` contained invalid UTF-8.".to_owned()).into()
})
}
fn prefix_path(
helper: &Helper,
_: &Handlebars,
ctx: &Context,
_: &mut RenderContext,
out: &mut dyn Output,
) -> HelperResult {
out
.write(
util::prefix_path(app_root(ctx)?, get_str(helper))
.to_str()
.ok_or_else(|| {
RenderErrorReason::Other(
"Either the `app.root-dir` or the specified path contained invalid UTF-8.".to_owned(),
)
})?,
)
.map_err(Into::into)
}
fn unprefix_path(
helper: &Helper,
_: &Handlebars,
ctx: &Context,
_: &mut RenderContext,
out: &mut dyn Output,
) -> HelperResult {
out
.write(
util::unprefix_path(app_root(ctx)?, get_str(helper))
.map_err(|_| {
RenderErrorReason::Other(
"Attempted to unprefix a path that wasn't in the app root dir.".to_owned(),
)
})?
.to_str()
.ok_or_else(|| {
RenderErrorReason::Other(
"Either the `app.root-dir` or the specified path contained invalid UTF-8.".to_owned(),
)
})?,
)
.map_err(Into::into)
}
/// pnpm's native binary (pnpm >= 11) runs package scripts without setting `PNPM_PACKAGE_NAME`,
/// and `npm_execpath` points at the binary itself, which is not necessarily named `pnpm`
/// (corepack downloads it as `pnpm-native`), so the user agent is what identifies it.
fn is_pnpm_run() -> bool {
std::env::var("npm_config_user_agent").is_ok_and(|user_agent| user_agent.starts_with("pnpm/"))
}
fn is_pnpm_dlx() -> bool {
var_os("NODE_PATH")
.map(PathBuf::from)
.is_some_and(|node_path| {
let mut iter = node_path.components().peekable();
while let Some(c) = iter.next() {
if c.as_os_str() == "pnpm" && iter.peek().is_some_and(|c| c.as_os_str() == "dlx") {
return true;
}
}
false
})
}

View File

@@ -0,0 +1,665 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use super::{
MobileTarget, OptionsHandle, detect_target_ok, ensure_init, env, get_app, get_config,
inject_resources, load_pbxproj, log_finished, open_and_wait, project_config,
synchronize_project_config,
};
use crate::{
ConfigValue, Error, Result,
build::Options as BuildOptions,
error::{Context, ErrorExt},
helpers::{
app_paths::Dirs,
config::{ConfigMetadata, get_config as get_tauri_config},
flock,
plist::merge_plist,
},
interface::{AppInterface, Options as InterfaceOptions},
mobile::{CliOptions, TargetDevice, ios::ensure_ios_runtime_installed, write_options},
};
use clap::{ArgAction, Parser, ValueEnum};
use cargo_mobile2::{
apple::{
config::Config as AppleConfig,
target::{ArchiveConfig, BuildConfig, ExportConfig, Target},
},
env::Env,
opts::{NoiseLevel, Profile},
target::{TargetInvalid, TargetTrait, call_for_targets_with_fallback},
};
use rand::distr::{Alphanumeric, SampleString};
use std::{
env::{set_current_dir, var, var_os},
fs,
path::{Path, PathBuf},
};
#[derive(Debug, Clone, Parser)]
#[clap(
about = "Build your app in release mode for iOS and generate IPAs",
long_about = "Build your app in release mode for iOS and generate IPAs. It makes use of the `build.frontendDist` property from your `tauri.conf.json` file. It also runs your `build.beforeBuildCommand` which usually builds your frontend into `build.frontendDist`."
)]
pub struct Options {
/// Builds with the debug flag
#[clap(short, long)]
pub debug: bool,
/// Which 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()))
)]
pub targets: Option<Vec<String>>,
/// List of cargo features to activate
#[clap(short, long, action = ArgAction::Append, num_args(0..), value_delimiter = ',')]
pub features: Vec<String>,
/// 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>,
/// Build number to append to the app version.
#[clap(long)]
pub build_number: Option<u32>,
/// Open Xcode
#[clap(short, long)]
pub open: bool,
/// Skip prompting for values
#[clap(long, env = "CI")]
pub ci: bool,
/// Describes how Xcode should export the archive.
///
/// Use this to create a package ready for the App Store (app-store-connect option) or TestFlight (release-testing option).
#[clap(long, value_enum)]
pub export_method: Option<ExportMethod>,
/// Command line arguments passed to the runner.
/// Use `--` to explicitly mark the start of the arguments.
/// e.g. `tauri ios build -- [runnerArgs]`.
#[clap(last(true))]
pub args: Vec<String>,
/// Do not error out if a version mismatch is detected on a Tauri package.
///
/// Only use this when you are sure the mismatch is incorrectly detected as version mismatched Tauri packages can lead to unknown behavior.
#[clap(long)]
pub ignore_version_mismatches: bool,
/// Skip code signing when bundling the app
#[clap(long)]
pub no_sign: bool,
/// Only archive the app, skip generating the IPA.
#[clap(long)]
pub archive_only: bool,
/// Target device of this build
#[clap(skip)]
pub target_device: Option<TargetDevice>,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum ExportMethod {
AppStoreConnect,
ReleaseTesting,
Debugging,
}
impl ExportMethod {
/// Xcode 15.4 deprecated these names (in this case we should use the Display impl).
pub fn pre_xcode_15_4_name(&self) -> String {
match self {
Self::AppStoreConnect => "app-store".to_string(),
Self::ReleaseTesting => "ad-hoc".to_string(),
Self::Debugging => "development".to_string(),
}
}
}
impl std::fmt::Display for ExportMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AppStoreConnect => write!(f, "app-store-connect"),
Self::ReleaseTesting => write!(f, "release-testing"),
Self::Debugging => write!(f, "debugging"),
}
}
}
impl std::str::FromStr for ExportMethod {
type Err = &'static str;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"app-store-connect" => Ok(Self::AppStoreConnect),
"release-testing" => Ok(Self::ReleaseTesting),
"debugging" => Ok(Self::Debugging),
_ => Err("unknown ios target"),
}
}
}
impl From<Options> for BuildOptions {
fn from(options: Options) -> Self {
Self {
runner: None,
debug: options.debug,
target: None,
features: options.features,
bundles: None,
no_bundle: false,
config: options.config,
args: options.args,
ci: options.ci,
skip_stapling: false,
ignore_version_mismatches: options.ignore_version_mismatches,
no_sign: options.no_sign,
no_binary_patching: false,
}
}
}
pub struct BuiltApplication {
pub config: AppleConfig,
pub interface: AppInterface,
// prevent drop
#[allow(dead_code)]
options_handle: OptionsHandle,
}
pub fn command(options: Options, noise_level: NoiseLevel) -> Result<BuiltApplication> {
let dirs = crate::helpers::app_paths::resolve_dirs();
run(options, noise_level, &dirs)
}
pub fn run(options: Options, noise_level: NoiseLevel, dirs: &Dirs) -> Result<BuiltApplication> {
let mut build_options: BuildOptions = options.clone().into();
build_options.target = Some(
Target::all()
.get(
options
.targets
.as_ref()
.and_then(|t| t.first())
.map(|t| t.as_str())
.unwrap_or(Target::DEFAULT_KEY),
)
.unwrap()
.triple
.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, build_options.target.clone(), dirs.tauri)?;
interface.build_options(&mut build_options.args, &mut build_options.features, true);
let app = get_app(MobileTarget::Ios, &tauri_config, &interface, dirs.tauri);
let (mut config, _) = get_config(
&app,
&tauri_config,
&build_options.features,
&CliOptions {
dev: false,
features: build_options.features.clone(),
args: build_options.args.clone(),
noise_level,
vars: Default::default(),
config: build_options.config.clone(),
target_device: None,
},
dirs.tauri,
)?;
set_current_dir(dirs.tauri).context("failed to set current directory")?;
ensure_init(
&tauri_config,
config.app(),
config.project_dir(),
MobileTarget::Ios,
options.ci,
)?;
inject_resources(&config, &tauri_config)?;
let mut plist = plist::Dictionary::new();
plist.insert(
"CFBundleShortVersionString".into(),
config.bundle_version_short().into(),
);
let info_plist_path = config
.project_dir()
.join(config.scheme())
.join("Info.plist");
let mut src_plists = vec![info_plist_path.clone().into()];
src_plists.push(plist::Value::Dictionary(plist).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());
}
if let Some(associations) = tauri_config.bundle.file_associations.as_ref() {
if let Some(file_associations) = tauri_utils::config::file_associations_plist(associations) {
src_plists.push(file_associations.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 env = env().context("failed to load iOS environment")?;
if !options.open {
ensure_ios_runtime_installed()?;
}
let mut export_options_plist = plist::Dictionary::new();
if let Some(method) = options.export_method {
let xcode_version =
crate::info::env_system::xcode_version().context("failed to determine Xcode version")?;
let mut iter = xcode_version.split('.');
let major = iter.next().context(format!(
"failed to parse Xcode version `{xcode_version}` as semver"
))?;
let minor = iter.next().context(format!(
"failed to parse Xcode version `{xcode_version}` as semver"
))?;
let major = major.parse::<u64>().ok().context(format!(
"failed to parse Xcode version `{xcode_version}` as semver: major is not a number"
))?;
let minor = minor.parse::<u64>().ok().context(format!(
"failed to parse Xcode version `{xcode_version}` as semver: minor is not a number"
))?;
if major < 15 || (major == 15 && minor < 4) {
export_options_plist.insert("method".to_string(), method.pre_xcode_15_4_name().into());
} else {
export_options_plist.insert("method".to_string(), method.to_string().into());
}
}
let (keychain, provisioning_profile) = super::signing_from_env()?;
let project_config = project_config(keychain.as_ref(), provisioning_profile.as_ref())?;
let mut pbxproj = load_pbxproj(&config)?;
// synchronize pbxproj and exportoptions
synchronize_project_config(
&config,
&tauri_config,
&mut pbxproj,
&mut export_options_plist,
&project_config,
options.debug,
)?;
if pbxproj.has_changes() {
pbxproj
.save()
.fs_context("failed to save pbxproj file", pbxproj.path)?;
}
// merge export options and write to temp file
let _export_options_tmp = if !export_options_plist.is_empty() {
let export_options_plist_path = config.project_dir().join("ExportOptions.plist");
let export_options =
tempfile::NamedTempFile::new().context("failed to create temporary file")?;
let merged_plist = merge_plist(vec![
export_options_plist_path.into(),
plist::Value::from(export_options_plist).into(),
])?;
merged_plist
.to_file_xml(export_options.path())
.map_err(std::io::Error::other)
.fs_context(
"failed to save export options plist file",
export_options.path().to_path_buf(),
)?;
config.set_export_options_plist_path(export_options.path());
Some(export_options)
} else {
None
};
let open = options.open;
let options_handle = run_build(
&interface,
options,
build_options,
tauri_config,
&mut config,
&mut env,
noise_level,
dirs,
)?;
if open {
open_and_wait(&config, &env);
}
Ok(BuiltApplication {
config,
interface,
options_handle,
})
}
#[allow(clippy::too_many_arguments)]
fn run_build(
interface: &AppInterface,
options: Options,
mut build_options: BuildOptions,
tauri_config: ConfigMetadata,
config: &mut AppleConfig,
env: &mut Env,
noise_level: NoiseLevel,
dirs: &Dirs,
) -> Result<OptionsHandle> {
let profile = if options.debug {
Profile::Debug
} else {
Profile::Release
};
crate::build::setup(interface, &mut build_options, &tauri_config, dirs, true)?;
let app_settings = interface.app_settings();
let out_dir = app_settings.out_dir(
&InterfaceOptions {
debug: build_options.debug,
target: build_options.target.clone(),
args: build_options.args.clone(),
..Default::default()
},
dirs.tauri,
)?;
let _lock = flock::open_rw(out_dir.join("lock").with_extension("ios"), "iOS")?;
let cli_options = CliOptions {
dev: false,
features: build_options.features.clone(),
args: build_options.args.clone(),
noise_level,
vars: Default::default(),
config: build_options.config.clone(),
target_device: options.target_device.clone(),
};
let handle = write_options(&tauri_config, cli_options)?;
if options.open {
return Ok(handle);
}
let mut out_files = Vec::new();
let force_skip_target_fallback = options.targets.as_ref().is_some_and(|t| t.is_empty());
call_for_targets_with_fallback(
options.targets.unwrap_or_default().iter(),
if force_skip_target_fallback {
&|_| None
} else {
&detect_target_ok
},
env,
|target: &Target| -> Result<()> {
let mut app_version = config.bundle_version().to_string();
if let Some(build_number) = options.build_number {
app_version.push('.');
app_version.push_str(&build_number.to_string());
}
let credentials = auth_credentials_from_env()?;
let skip_signing = options.no_sign || credentials.is_some();
if !(options.archive_only || options.no_sign) {
let mut build_config = BuildConfig::new().allow_provisioning_updates();
if let Some(credentials) = &credentials {
build_config = build_config.authentication_credentials(credentials.clone());
}
if skip_signing {
build_config = build_config.skip_codesign();
}
target
.build(None, config, env, noise_level, profile, build_config)
.context("failed to build iOS app")?;
}
let mut archive_config = ArchiveConfig::new().allow_provisioning_updates();
if let Some(credentials) = &credentials {
archive_config = archive_config.authentication_credentials(credentials.clone());
}
if skip_signing {
archive_config = archive_config.skip_codesign();
}
target
.archive(
config,
env,
noise_level,
profile,
Some(app_version),
archive_config,
)
.context("failed to archive iOS app")?;
if options.archive_only {
out_files.push(
config
.archive_dir()
.join(format!("{}.xcarchive", config.scheme())),
);
return Ok(());
}
let out_dir = config.export_dir().join(target.arch);
if target.sdk == "iphonesimulator" {
fs::create_dir_all(&out_dir)
.fs_context("failed to create Xcode output directory", out_dir.clone())?;
let app_path = config
.archive_dir()
.join(format!("{}.xcarchive", config.scheme()))
.join("Products")
.join("Applications")
.join(config.app().stylized_name())
.with_extension("app");
let path = out_dir.join(app_path.file_name().unwrap());
// `rename` does not replace a non-empty directory, such as the previous build
if path.exists() {
fs::remove_dir_all(&path).fs_context("failed to remove previous app", path.clone())?;
}
fs::rename(&app_path, &path).fs_context("failed to rename app", app_path)?;
out_files.push(path);
} else if options.no_sign {
fs::create_dir_all(&out_dir)
.fs_context("failed to create Xcode output directory", out_dir.clone())?;
let app_path = config
.archive_dir()
.join(format!("{}.xcarchive", config.scheme()))
.join("Products")
.join("Applications")
.join(config.app().stylized_name())
.with_extension("app");
let ipa_path = out_dir
.join(config.app().stylized_name())
.with_extension("ipa");
create_ipa(&app_path, &ipa_path)?;
out_files.push(ipa_path);
} else {
// if we skipped code signing, we do not have the entitlements applied to our exported IPA
// we must force sign the app binary with a dummy certificate just to preserve the entitlements
// target.export() will sign it with an actual certificate for us
if skip_signing {
let password = Alphanumeric.sample_string(&mut rand::rng(), 16);
let certificate = tauri_macos_sign::certificate::generate_self_signed(
tauri_macos_sign::certificate::SelfSignedCertificateRequest {
algorithm: "rsa".to_string(),
profile: tauri_macos_sign::certificate::CertificateProfile::AppleDistribution,
team_id: "unset".to_string(),
person_name: "Tauri".to_string(),
country_name: "NL".to_string(),
validity_days: 365,
password: password.clone(),
},
)
.map_err(Box::new)?;
let tmp_dir = tempfile::tempdir().context("failed to create temporary directory")?;
let cert_path = tmp_dir.path().join("cert.p12");
std::fs::write(&cert_path, certificate)
.fs_context("failed to write certificate", cert_path.clone())?;
let self_signed_cert_keychain =
tauri_macos_sign::Keychain::with_certificate_file(&cert_path, &password.into())
.map_err(Box::new)?;
let app_dir = config
.export_dir()
.join(format!("{}.xcarchive", config.scheme()))
.join("Products/Applications")
.join(format!("{}.app", config.app().stylized_name()));
self_signed_cert_keychain
.sign(
&app_dir.join(config.app().stylized_name()),
Some(
&config
.project_dir()
.join(config.scheme())
.join(format!("{}.entitlements", config.scheme())),
),
false,
)
.map_err(Box::new)?;
}
let mut export_config = ExportConfig::new().allow_provisioning_updates();
if let Some(credentials) = &credentials {
export_config = export_config.authentication_credentials(credentials.clone());
}
target
.export(config, env, noise_level, export_config)
.context("failed to export iOS app")?;
if let Ok(ipa_path) = config.ipa_path() {
fs::create_dir_all(&out_dir)
.fs_context("failed to create Xcode output directory", out_dir.clone())?;
let path = out_dir.join(ipa_path.file_name().unwrap());
fs::rename(&ipa_path, &path).fs_context("failed to rename IPA", ipa_path)?;
out_files.push(path);
}
}
Ok(())
},
)
.map_err(|e: TargetInvalid| Error::GenericError(e.to_string()))??;
if !out_files.is_empty() {
log_finished(out_files, "iOS Bundle");
}
Ok(handle)
}
fn create_ipa(app_path: &Path, ipa_path: &Path) -> Result<()> {
let ipa_file =
fs::File::create(ipa_path).fs_context("failed to create IPA file", ipa_path.to_path_buf())?;
let mut zip = zip::ZipWriter::new(ipa_file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(0o755);
zip
.add_directory("Payload/", options)
.context("failed to add Payload directory to zip")?;
let mut app_files = Vec::new();
let mut stack = vec![app_path.to_path_buf()];
while let Some(path) = stack.pop() {
if path.is_dir() {
app_files.push(path.clone());
for entry in fs::read_dir(&path).fs_context("failed to read directory", path.clone())? {
stack.push(
entry
.fs_context("failed to read directory entry", path.clone())?
.path(),
);
}
} else {
app_files.push(path);
}
}
for file_path in app_files {
let name = file_path.strip_prefix(app_path.parent().unwrap()).unwrap();
let mut name_str = name.to_string_lossy().to_string();
// zip expects forward slashes
if std::path::MAIN_SEPARATOR == '\\' {
name_str = name_str.replace('\\', "/");
}
let mut name_in_zip = format!("Payload/{name_str}");
if file_path.is_dir() {
name_in_zip.push('/');
zip
.add_directory(name_in_zip, options)
.context("failed to add directory to zip")?;
} else {
zip
.start_file(name_in_zip, options)
.context("failed to start file in zip")?;
let mut f = fs::File::open(&file_path).fs_context("failed to open file", file_path)?;
std::io::copy(&mut f, &mut zip).context("failed to copy file to zip")?;
}
}
zip.finish().context("failed to finish zip")?;
Ok(())
}
fn auth_credentials_from_env() -> Result<Option<cargo_mobile2::apple::AuthCredentials>> {
match (
var("APPLE_API_KEY"),
var("APPLE_API_ISSUER"),
var_os("APPLE_API_KEY_PATH").map(PathBuf::from),
) {
(Ok(key_id), Ok(key_issuer_id), Some(key_path)) => {
Ok(Some(cargo_mobile2::apple::AuthCredentials {
key_path,
key_id,
key_issuer_id,
}))
}
(Err(_), Err(_), None) => Ok(None),
_ => crate::error::bail!(
"APPLE_API_KEY, APPLE_API_ISSUER and APPLE_API_KEY_PATH must be provided for code signing"
),
}
}

View File

@@ -0,0 +1,407 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use super::{
MobileTarget, ProjectConfig, device_prompt, ensure_init, env, get_app, get_config,
inject_resources, load_pbxproj, open_and_wait, synchronize_project_config,
};
use crate::{
ConfigValue, Result,
dev::Options as DevOptions,
error::{Context, ErrorExt},
helpers::{
app_paths::Dirs,
config::{ConfigMetadata, get_config as get_tauri_config},
flock,
plist::merge_plist,
},
interface::{AppInterface, MobileOptions, Options as InterfaceOptions},
mobile::{
CliOptions, DevChild, DevHost, DevProcess, ios::ensure_ios_runtime_installed,
use_network_address_for_dev_url, write_options,
},
};
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 {
unsafe {
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());
}
if let Some(associations) = tauri_config.bundle.file_associations.as_ref() {
if let Some(file_associations) = tauri_utils::config::file_associations_plist(associations) {
src_plists.push(file_associations.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)
}

View File

@@ -0,0 +1,704 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use cargo_mobile2::{
apple::{
config::{
Config as AppleConfig, Metadata as AppleMetadata, Platform as ApplePlatform,
Raw as RawAppleConfig,
},
device::{self, Device},
target::Target,
teams::find_development_teams,
},
config::app::{App, DEFAULT_ASSET_DIR},
env::Env,
opts::NoiseLevel,
os,
util::{prompt, relativize_path},
};
use clap::{Parser, Subcommand};
use serde::Deserialize;
use sublime_fuzzy::best_match;
use tauri_utils::resources::ResourcePaths;
use super::{
CliOptions, MIN_DEVICE_MATCH_SCORE, OptionsHandle, Target as MobileTarget, ensure_init, env,
get_app, init::command as init_command, log_finished, read_options,
};
use crate::{
ConfigValue, Error, Result,
error::{Context, ErrorExt},
helpers::{
config::{BundleResources, Config as TauriConfig, ConfigMetadata},
pbxproj, strip_semver_prerelease_tag,
},
};
use std::{
env::{set_var, var_os},
fs::create_dir_all,
path::Path,
str::FromStr,
thread::sleep,
time::Duration,
};
mod build;
mod dev;
pub(crate) mod project;
mod run;
mod xcode_script;
pub const APPLE_DEVELOPMENT_TEAM_ENV_VAR_NAME: &str = "APPLE_DEVELOPMENT_TEAM";
pub const LIB_OUTPUT_FILE_NAME: &str = "libapp.a";
#[derive(Parser)]
#[clap(
author,
version,
about = "iOS commands",
subcommand_required(true),
arg_required_else_help(true)
)]
pub struct Cli {
#[clap(subcommand)]
command: Commands,
}
#[derive(Debug, Parser)]
#[clap(about = "Initialize iOS target in the project")]
pub struct InitOptions {
/// Skip prompting for values
#[clap(long, env = "CI")]
ci: bool,
/// Reinstall dependencies
#[clap(short, long)]
reinstall_deps: bool,
/// Skips installing rust toolchains via rustup
#[clap(long)]
skip_targets_install: 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>,
}
#[derive(Subcommand)]
enum Commands {
Init(InitOptions),
Dev(dev::Options),
Build(build::Options),
Run(run::Options),
#[clap(hide(true))]
XcodeScript(xcode_script::Options),
}
pub fn command(cli: Cli, verbosity: u8) -> Result<()> {
let noise_level = NoiseLevel::from_occurrences(verbosity as u64);
match cli.command {
Commands::Init(options) => init_command(
MobileTarget::Ios,
options.ci,
options.reinstall_deps,
options.skip_targets_install,
options.config,
)?,
Commands::Dev(options) => dev::command(options, noise_level)?,
Commands::Build(options) => build::command(options, noise_level).map(|_| ())?,
Commands::Run(options) => run::command(options, noise_level)?,
Commands::XcodeScript(options) => xcode_script::command(options)?,
}
Ok(())
}
pub fn get_config(
app: &App,
tauri_config: &TauriConfig,
features: &[String],
cli_options: &CliOptions,
tauri_dir: &Path,
) -> Result<(AppleConfig, AppleMetadata)> {
let mut ios_options = cli_options.clone();
ios_options.features.extend_from_slice(features);
let bundle_version = if let Some(bundle_version) = tauri_config
.bundle
.ios
.bundle_version
.clone()
.or_else(|| tauri_config.version.clone())
{
// if it's a semver string, we must strip the prerelease tag
if let Ok(mut version) = semver::Version::from_str(&bundle_version) {
if !version.pre.is_empty() {
log::warn!("CFBundleVersion cannot have prerelease tag; stripping from {bundle_version}");
strip_semver_prerelease_tag(&mut version)?;
}
// correctly serialize version - cannot contain `+` as build metadata separator
Some(format!(
"{}.{}.{}{}",
version.major,
version.minor,
version.patch,
if version.build.is_empty() {
"".to_string()
} else {
format!(".{}", version.build.as_str())
}
))
} else {
// let it go as is - cargo-mobile2 will validate it
Some(bundle_version)
}
} else {
None
};
let full_bundle_version_short = if let Some(app_version) = &tauri_config.version {
if let Ok(mut version) = semver::Version::from_str(app_version) {
if !version.pre.is_empty() {
log::warn!(
"CFBundleShortVersionString cannot have prerelease tag; stripping from {app_version}"
);
strip_semver_prerelease_tag(&mut version)?;
}
// correctly serialize version - cannot contain `+` as build metadata separator
Some(format!(
"{}.{}.{}{}",
version.major,
version.minor,
version.patch,
if version.build.is_empty() {
"".to_string()
} else {
format!(".{}", version.build.as_str())
}
))
} else {
// let it go as is - cargo-mobile2 will validate it
Some(app_version.clone())
}
} else {
bundle_version.clone()
};
let bundle_version_short = if let Some(full_version) = full_bundle_version_short.as_deref() {
let mut s = full_version.split('.');
let short_version = format!(
"{}.{}.{}",
s.next().unwrap_or("0"),
s.next().unwrap_or("0"),
s.next().unwrap_or("0")
);
if short_version != full_version {
log::warn!(
"{full_version:?} is not a valid CFBundleShortVersionString since it must contain exactly three dot separated integers; setting it to {short_version} instead"
);
}
Some(short_version)
} else {
None
};
let raw = RawAppleConfig {
development_team: std::env::var(APPLE_DEVELOPMENT_TEAM_ENV_VAR_NAME)
.ok()
.or_else(|| tauri_config.bundle.ios.development_team.clone())
.or_else(|| {
let teams = find_development_teams().unwrap_or_default();
match teams.len() {
0 => {
log::warn!("No code signing certificates found. You must add one and set the certificate development team ID on the `bundle > iOS > developmentTeam` config value or the `{APPLE_DEVELOPMENT_TEAM_ENV_VAR_NAME}` environment variable. To list the available certificates, run `tauri info`.");
None
}
1 => None,
_ => {
log::warn!("You must set the code signing certificate development team ID on the `bundle > iOS > developmentTeam` config value or the `{APPLE_DEVELOPMENT_TEAM_ENV_VAR_NAME}` environment variable. Available certificates: {}", teams.iter().map(|t| format!("{} (ID: {})", t.name, t.id)).collect::<Vec<String>>().join(", "));
None
}
}
}),
ios_features: Some(ios_options.features.clone()),
bundle_version,
bundle_version_short,
ios_version: Some(tauri_config.bundle.ios.minimum_system_version.clone()),
..Default::default()
};
let config = AppleConfig::from_raw(app.clone(), Some(raw))
.context("failed to create Apple configuration")?;
let mut vendor_frameworks = Vec::new();
let mut frameworks = Vec::new();
for framework in tauri_config
.bundle
.ios
.frameworks
.clone()
.unwrap_or_default()
{
let framework_path = Path::new(&framework);
let ext = framework_path.extension().unwrap_or_default();
if ext.is_empty() {
frameworks.push(framework);
} else if ext == "framework" {
frameworks.push(
framework_path
.file_stem()
.unwrap()
.to_string_lossy()
.to_string(),
);
} else {
vendor_frameworks.push(
relativize_path(tauri_dir.join(framework_path), config.project_dir())
.to_string_lossy()
.to_string(),
);
}
}
let metadata = AppleMetadata {
supported: true,
ios: ApplePlatform {
cargo_args: Some(ios_options.args),
features: Some(ios_options.features),
frameworks: Some(frameworks),
vendor_frameworks: Some(vendor_frameworks),
..Default::default()
},
macos: Default::default(),
};
unsafe { set_var("TAURI_IOS_PROJECT_PATH", config.project_dir()) };
unsafe { set_var("TAURI_IOS_APP_NAME", config.app().name()) };
Ok((config, metadata))
}
fn connected_device_prompt<'a>(env: &'_ Env, target: Option<&str>) -> Result<Device<'a>> {
let device_list = device::list_devices(env).map_err(|cause| {
Error::GenericError(format!("Failed to detect connected iOS devices: {cause}"))
})?;
if !device_list.is_empty() {
let device = if let Some(t) = target {
let (device, score) = device_list
.into_iter()
.rev()
.map(|d| {
let score = best_match(t, d.name()).map_or(0, |m| m.score());
(d, score)
})
.max_by_key(|(_, score)| *score)
// we already checked the list is not empty
.unwrap();
if score > MIN_DEVICE_MATCH_SCORE {
device
} else {
crate::error::bail!("Could not find an iOS device matching {t}")
}
} else {
let index = if device_list.len() > 1 {
prompt::list(
concat!("Detected ", "iOS", " devices"),
device_list.iter(),
"device",
None,
"Device",
)
.context("failed to prompt for device")?
} else {
0
};
device_list.into_iter().nth(index).unwrap()
};
println!(
"Detected connected device: {} with target {:?}",
device,
device.target().triple,
);
Ok(device)
} else {
crate::error::bail!("No connected iOS devices detected")
}
}
#[derive(Default, Deserialize)]
struct InstalledRuntimesList {
runtimes: Vec<InstalledRuntime>,
}
#[derive(Deserialize)]
struct InstalledRuntime {
name: String,
}
fn simulator_prompt(env: &'_ Env, target: Option<&str>) -> Result<device::Simulator> {
let simulator_list = device::list_simulators(env).map_err(|cause| {
Error::GenericError(format!(
"Failed to detect connected iOS Simulator devices: {cause}"
))
})?;
if !simulator_list.is_empty() {
let device = if let Some(t) = target {
let (device, score) = simulator_list
.into_iter()
.rev()
.map(|d| {
let score = best_match(t, d.name()).map_or(0, |m| m.score());
(d, score)
})
.max_by_key(|(_, score)| *score)
// we already checked the list is not empty
.unwrap();
if score > MIN_DEVICE_MATCH_SCORE {
device
} else {
crate::error::bail!("Could not find an iOS Simulator matching {t}")
}
} else if simulator_list.len() > 1 {
let index = prompt::list(
concat!("Detected ", "iOS", " simulators"),
simulator_list.iter(),
"simulator",
None,
"Simulator",
)
.context("failed to prompt for simulator")?;
simulator_list.into_iter().nth(index).unwrap()
} else {
simulator_list.into_iter().next().unwrap()
};
Ok(device)
} else {
log::warn!("No available iOS Simulator detected");
let install_ios = crate::helpers::prompts::confirm(
"Would you like to install the latest iOS runtime?",
Some(false),
)
.unwrap_or_default();
if install_ios {
duct::cmd("xcodebuild", ["-downloadPlatform", "iOS"])
.stdout_file(os_pipe::dup_stdout().unwrap())
.stderr_file(os_pipe::dup_stderr().unwrap())
.run()
.map_err(|e| Error::CommandFailed {
command: "xcodebuild -downloadPlatform iOS".to_string(),
error: e,
})?;
return simulator_prompt(env, target);
}
crate::error::bail!("No available iOS Simulator detected")
}
}
fn device_prompt<'a>(env: &'_ Env, target: Option<&str>) -> Result<Device<'a>> {
if let Ok(device) = connected_device_prompt(env, target) {
Ok(device)
} else {
let simulator = simulator_prompt(env, target)?;
log::info!("Starting simulator {}", simulator.name());
simulator
.start_detached(env)
.context("failed to start simulator")?;
Ok(simulator.into())
}
}
fn ensure_ios_runtime_installed() -> Result<()> {
let installed_platforms_json = duct::cmd("xcrun", ["simctl", "list", "runtimes", "--json"])
.read()
.map_err(|e| Error::CommandFailed {
command: "xcrun simctl list runtimes --json".to_string(),
error: e,
})?;
let installed_platforms: InstalledRuntimesList =
serde_json::from_str(&installed_platforms_json).unwrap_or_default();
if !installed_platforms
.runtimes
.iter()
.any(|r| r.name.starts_with("iOS"))
{
log::warn!("iOS platform not installed");
let install_ios = crate::helpers::prompts::confirm(
"Would you like to install the latest iOS runtime?",
Some(false),
)
.unwrap_or_default();
if install_ios {
duct::cmd("xcodebuild", ["-downloadPlatform", "iOS"])
.stdout_file(os_pipe::dup_stdout().unwrap())
.stderr_file(os_pipe::dup_stderr().unwrap())
.run()
.map_err(|e| Error::CommandFailed {
command: "xcodebuild -downloadPlatform iOS".to_string(),
error: e,
})?;
} else {
crate::error::bail!("iOS platform not installed");
}
}
Ok(())
}
fn detect_target_ok<'a>(env: &Env) -> Option<&'a Target<'a>> {
device_prompt(env, None).map(|device| device.target()).ok()
}
fn open_and_wait(config: &AppleConfig, env: &Env) -> ! {
log::info!("Opening Xcode");
if let Err(e) = os::open_file_with("Xcode", config.project_dir(), env) {
log::error!("{e}");
}
loop {
sleep(Duration::from_secs(24 * 60 * 60));
}
}
fn inject_resources(config: &AppleConfig, tauri_config: &TauriConfig) -> Result<()> {
let asset_dir = config.project_dir().join(DEFAULT_ASSET_DIR);
create_dir_all(&asset_dir).fs_context("failed to create asset directory", asset_dir.clone())?;
let resources = match &tauri_config.bundle.resources {
Some(BundleResources::List(paths)) => Some(ResourcePaths::new(paths.as_slice(), true)),
Some(BundleResources::Map(map)) => Some(ResourcePaths::from_map(map, true)),
None => None,
};
if let Some(resources) = resources {
for resource in resources.iter() {
let resource = resource.context("failed to get resource")?;
let dest = asset_dir.join(resource.target());
crate::helpers::fs::copy_file(resource.path(), dest)?;
}
}
Ok(())
}
pub fn signing_from_env() -> Result<(
Option<tauri_macos_sign::Keychain>,
Option<tauri_macos_sign::ProvisioningProfile>,
)> {
let keychain = match (
var_os("IOS_CERTIFICATE"),
var_os("IOS_CERTIFICATE_PASSWORD"),
) {
(Some(certificate), Some(certificate_password)) => {
log::info!("Reading iOS certificates from ");
tauri_macos_sign::Keychain::with_certificate(&certificate, &certificate_password)
.map(Some)
.map_err(Box::new)?
}
(Some(_), None) => {
log::warn!(
"The IOS_CERTIFICATE environment variable is set but not IOS_CERTIFICATE_PASSWORD. Ignoring the certificate..."
);
None
}
_ => None,
};
let provisioning_profile = if let Some(provisioning_profile) = var_os("IOS_MOBILE_PROVISION") {
tauri_macos_sign::ProvisioningProfile::from_base64(&provisioning_profile)
.map(Some)
.map_err(Box::new)?
} else {
if keychain.is_some() {
log::warn!(
"You have provided an iOS certificate via environment variables but the IOS_MOBILE_PROVISION environment variable is not set. This will fail when signing unless the profile is set in your Xcode project."
);
}
None
};
Ok((keychain, provisioning_profile))
}
pub struct ProjectConfig {
pub code_sign_identity: Option<String>,
pub team_id: Option<String>,
pub provisioning_profile_uuid: Option<String>,
}
pub fn project_config(
keychain: Option<&tauri_macos_sign::Keychain>,
provisioning_profile: Option<&tauri_macos_sign::ProvisioningProfile>,
) -> Result<ProjectConfig> {
Ok(ProjectConfig {
code_sign_identity: keychain.map(|k| k.signing_identity()),
team_id: keychain.and_then(|k| k.team_id().map(ToString::to_string)),
provisioning_profile_uuid: provisioning_profile.and_then(|p| p.uuid().ok()),
})
}
pub fn load_pbxproj(config: &AppleConfig) -> Result<pbxproj::Pbxproj> {
pbxproj::parse(
config
.project_dir()
.join(format!("{}.xcodeproj", config.app().name()))
.join("project.pbxproj"),
)
}
pub fn synchronize_project_config(
config: &AppleConfig,
tauri_config: &ConfigMetadata,
pbxproj: &mut pbxproj::Pbxproj,
export_options_plist: &mut plist::Dictionary,
project_config: &ProjectConfig,
debug: bool,
) -> Result<()> {
let identifier = tauri_config.identifier.clone();
let product_name = tauri_config.product_name.clone();
let manual_signing = project_config.code_sign_identity.is_some()
|| project_config.provisioning_profile_uuid.is_some();
if let Some(xc_configuration_list) = pbxproj
.xc_configuration_list
.clone()
.into_values()
.find(|l| l.comment.contains("_iOS"))
{
for build_configuration_ref in xc_configuration_list.build_configurations {
if manual_signing {
pbxproj.set_build_settings(&build_configuration_ref.id, "CODE_SIGN_STYLE", "Manual");
}
if let Some(team) = config.development_team() {
let team = format!("\"{team}\"");
pbxproj.set_build_settings(&build_configuration_ref.id, "DEVELOPMENT_TEAM", &team);
}
pbxproj.set_build_settings(
&build_configuration_ref.id,
"PRODUCT_BUNDLE_IDENTIFIER",
&identifier,
);
if let Some(product_name) = &product_name {
pbxproj.set_build_settings(
&build_configuration_ref.id,
"PRODUCT_NAME",
&format!("\"{product_name}\""),
);
}
if let Some(identity) = &project_config.code_sign_identity {
let identity = format!("\"{identity}\"");
pbxproj.set_build_settings(&build_configuration_ref.id, "CODE_SIGN_IDENTITY", &identity);
pbxproj.set_build_settings(
&build_configuration_ref.id,
"\"CODE_SIGN_IDENTITY[sdk=iphoneos*]\"",
&identity,
);
}
if let Some(id) = &project_config.team_id {
let id = format!("\"{id}\"");
pbxproj.set_build_settings(&build_configuration_ref.id, "DEVELOPMENT_TEAM", &id);
pbxproj.set_build_settings(
&build_configuration_ref.id,
"\"DEVELOPMENT_TEAM[sdk=iphoneos*]\"",
&id,
);
}
if let Some(profile_uuid) = &project_config.provisioning_profile_uuid {
let profile_uuid = format!("\"{profile_uuid}\"");
pbxproj.set_build_settings(
&build_configuration_ref.id,
"PROVISIONING_PROFILE_SPECIFIER",
&profile_uuid,
);
pbxproj.set_build_settings(
&build_configuration_ref.id,
"\"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]\"",
&profile_uuid,
);
}
}
}
let build_configuration = {
if let Some(xc_configuration_list) = pbxproj
.xc_configuration_list
.clone()
.into_values()
.find(|l| l.comment.contains("_iOS"))
{
let mut configuration = None;
let target = if debug { "debug" } else { "release" };
for build_configuration_ref in xc_configuration_list.build_configurations {
if build_configuration_ref.comments.contains(target) {
configuration = pbxproj
.xc_build_configuration
.get(&build_configuration_ref.id);
break;
}
}
configuration
} else {
None
}
};
if let Some(build_configuration) = build_configuration {
if let Some(style) = build_configuration.get_build_setting("CODE_SIGN_STYLE") {
export_options_plist.insert(
"signingStyle".to_string(),
style.value.to_lowercase().into(),
);
} else {
export_options_plist.insert("signingStyle".to_string(), "automatic".into());
}
if manual_signing {
if let Some(identity) = build_configuration
.get_build_setting("\"CODE_SIGN_IDENTITY[sdk=iphoneos*]\"")
.or_else(|| build_configuration.get_build_setting("CODE_SIGN_IDENTITY"))
{
export_options_plist.insert(
"signingCertificate".to_string(),
identity.value.trim_matches('"').into(),
);
}
let profile_uuid = project_config
.provisioning_profile_uuid
.clone()
.or_else(|| {
build_configuration
.get_build_setting("\"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]\"")
.or_else(|| build_configuration.get_build_setting("PROVISIONING_PROFILE_SPECIFIER"))
.map(|setting| setting.value.trim_matches('"').to_string())
});
if let Some(profile_uuid) = profile_uuid {
let mut provisioning_profiles = plist::Dictionary::new();
provisioning_profiles.insert(config.app().identifier().to_string(), profile_uuid.into());
export_options_plist.insert(
"provisioningProfiles".to_string(),
provisioning_profiles.into(),
);
}
}
if let Some(id) = build_configuration
.get_build_setting("\"DEVELOPMENT_TEAM[sdk=iphoneos*]\"")
.or_else(|| build_configuration.get_build_setting("DEVELOPMENT_TEAM"))
{
export_options_plist.insert("teamID".to_string(), id.value.trim_matches('"').into());
}
}
Ok(())
}

View File

@@ -0,0 +1,239 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::{
Error, ErrorExt, Result,
error::Context,
helpers::{config::Config as TauriConfig, template},
mobile::ios::LIB_OUTPUT_FILE_NAME,
};
use cargo_mobile2::{
apple::{
config::{Config, Metadata},
deps,
target::Target,
},
config::app::DEFAULT_ASSET_DIR,
target::TargetTrait as _,
util::{self, cli::TextWrapper},
};
use handlebars::Handlebars;
use include_dir::{Dir, include_dir};
use std::{
ffi::OsString,
fs::{OpenOptions, create_dir_all},
path::{Component, PathBuf},
};
const TEMPLATE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/templates/mobile/ios");
// unprefixed app_root seems pretty dangerous!!
// TODO: figure out what cargo-mobile meant by that
#[allow(clippy::too_many_arguments)]
pub fn generate(
tauri_config: &TauriConfig,
config: &Config,
metadata: &Metadata,
(handlebars, mut map): (Handlebars, template::JsonMap),
wrapper: &TextWrapper,
non_interactive: bool,
reinstall_deps: bool,
skip_targets_install: bool,
) -> Result<()> {
if !skip_targets_install {
let installed_targets =
crate::interface::rust::installation::installed_targets().unwrap_or_default();
let missing_targets = Target::all()
.values()
.filter(|t| !installed_targets.contains(&t.triple().into()))
.collect::<Vec<&Target>>();
if !missing_targets.is_empty() {
log::info!("Installing iOS Rust targets...");
for target in missing_targets {
log::info!("Installing target {}", target.triple());
target.install().map_err(|error| Error::CommandFailed {
command: "rustup target add".to_string(),
error,
})?;
}
}
}
deps::install_all(wrapper, non_interactive, true, reinstall_deps).map_err(|error| {
Error::CommandFailed {
command: "pod install".to_string(),
error: std::io::Error::other(error),
}
})?;
let dest = config.project_dir();
let rel_prefix = util::relativize_path(config.app().root_dir(), &dest);
let source_dirs = vec![rel_prefix.join("src")];
let asset_catalogs = metadata.ios().asset_catalogs().unwrap_or_default();
let ios_pods = metadata.ios().pods().unwrap_or_default();
let macos_pods = metadata.macos().pods().unwrap_or_default();
#[cfg(target_arch = "aarch64")]
let default_archs = ["arm64"];
#[cfg(not(target_arch = "aarch64"))]
let default_archs = ["arm64", "x86_64"];
map.insert("lib-output-file-name", LIB_OUTPUT_FILE_NAME);
map.insert("file-groups", &source_dirs);
map.insert("ios-frameworks", metadata.ios().frameworks());
map.insert("ios-valid-archs", default_archs);
map.insert("ios-vendor-frameworks", metadata.ios().vendor_frameworks());
map.insert("ios-vendor-sdks", metadata.ios().vendor_sdks());
map.insert("macos-frameworks", metadata.macos().frameworks());
map.insert(
"macos-vendor-frameworks",
metadata.macos().vendor_frameworks(),
);
map.insert("macos-vendor-sdks", metadata.macos().vendor_frameworks());
map.insert("asset-catalogs", asset_catalogs);
map.insert("ios-pods", ios_pods);
map.insert("macos-pods", macos_pods);
map.insert(
"ios-additional-targets",
metadata.ios().additional_targets(),
);
map.insert(
"macos-additional-targets",
metadata.macos().additional_targets(),
);
map.insert("ios-pre-build-scripts", metadata.ios().pre_build_scripts());
map.insert(
"ios-post-compile-scripts",
metadata.ios().post_compile_scripts(),
);
map.insert(
"ios-post-build-scripts",
metadata.ios().post_build_scripts(),
);
map.insert(
"macos-pre-build-scripts",
metadata.macos().pre_build_scripts(),
);
map.insert(
"macos-post-compile-scripts",
metadata.macos().post_compile_scripts(),
);
map.insert(
"macos-post-build-scripts",
metadata.macos().post_build_scripts(),
);
map.insert(
"ios-command-line-arguments",
metadata.ios().command_line_arguments(),
);
map.insert(
"macos-command-line-arguments",
metadata.macos().command_line_arguments(),
);
let mut created_dirs = Vec::new();
template::render_with_generator(
&handlebars,
map.inner(),
&TEMPLATE_DIR,
&dest,
&mut |path| {
let mut components: Vec<_> = path.components().collect();
let mut new_component = None;
for component in &mut components {
if let Component::Normal(c) = component {
let c = c.to_string_lossy();
if c.contains("{{app.name}}") {
new_component.replace(OsString::from(
&c.replace("{{app.name}}", config.app().name()),
));
*component = Component::Normal(new_component.as_ref().unwrap());
break;
}
}
}
let path = dest.join(components.iter().collect::<PathBuf>());
let parent = path.parent().unwrap().to_path_buf();
if !created_dirs.contains(&parent) {
create_dir_all(&parent)?;
created_dirs.push(parent);
}
let mut options = OpenOptions::new();
options.write(true);
if !path.exists() {
options.create(true).open(path).map(Some)
} else {
Ok(None)
}
},
)
.with_context(|| "failed to process template")?;
if let Some(template_path) = tauri_config.bundle.ios.template.as_ref() {
let template = std::fs::read_to_string(template_path).fs_context(
"failed to read custom Xcode project template",
template_path.to_path_buf(),
)?;
let mut output_file = std::fs::File::create(dest.join("project.yml")).fs_context(
"failed to create project.yml file",
dest.join("project.yml"),
)?;
handlebars
.render_template_to_write(&template, map.inner(), &mut output_file)
.expect("Failed to render template");
}
let mut dirs_to_create = asset_catalogs.to_vec();
dirs_to_create.push(dest.join(DEFAULT_ASSET_DIR));
dirs_to_create.push(dest.join("Externals"));
dirs_to_create.push(dest.join(format!("{}_iOS", config.app().name())));
// Create all required project directories if they don't already exist
for dir in &dirs_to_create {
std::fs::create_dir_all(dir).fs_context("failed to create directory", dir.to_path_buf())?;
}
// Note that Xcode doesn't always reload the project nicely; reopening is
// often necessary.
println!("Generating Xcode project...");
duct::cmd(
"xcodegen",
[
"generate",
"--spec",
&dest.join("project.yml").to_string_lossy(),
],
)
.stdout_file(os_pipe::dup_stdout().unwrap())
.stderr_file(os_pipe::dup_stderr().unwrap())
.run()
.map_err(|error| Error::CommandFailed {
command: "xcodegen".to_string(),
error,
})?;
if !ios_pods.is_empty() || !macos_pods.is_empty() {
duct::cmd(
"pod",
[
"install",
&format!("--project-directory={}", dest.display()),
],
)
.stdout_file(os_pipe::dup_stdout().unwrap())
.stderr_file(os_pipe::dup_stderr().unwrap())
.run()
.map_err(|error| Error::CommandFailed {
command: "pod install".to_string(),
error,
})?;
}
Ok(())
}

View File

@@ -0,0 +1,144 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::path::PathBuf;
use cargo_mobile2::opts::{NoiseLevel, Profile};
use clap::{ArgAction, Parser};
use super::{device_prompt, env};
use crate::{
ConfigValue, Result,
error::Context,
helpers::config::{ConfigMetadata, get_config as get_tauri_config},
interface::{DevProcess, WatcherOptions},
mobile::{DevChild, TargetDevice},
};
#[derive(Debug, Clone, Parser)]
#[clap(
about = "Run your app in production mode on iOS",
long_about = "Run your app in production mode on iOS. It makes use of the `build.frontendDist` property from your `tauri.conf.json` file. It also runs your `build.beforeBuildCommand` which usually builds your frontend into `build.frontendDist`."
)]
pub struct Options {
/// Run the app in release mode
#[clap(short, long)]
pub release: bool,
/// List of cargo features to activate
#[clap(short, long, action = ArgAction::Append, num_args(0..), value_delimiter = ',')]
pub features: Vec<String>,
/// 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>,
/// 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
#[clap(short, long)]
pub open: bool,
/// Runs on the given device name
pub device: Option<String>,
/// Command line arguments passed to the runner.
/// Use `--` to explicitly mark the start of the arguments.
/// e.g. `tauri android build -- [runnerArgs]`.
#[clap(last(true))]
pub args: Vec<String>,
/// Do not error out if a version mismatch is detected on a Tauri package.
///
/// Only use this when you are sure the mismatch is incorrectly detected as version mismatched Tauri packages can lead to unknown behavior.
#[clap(long)]
pub ignore_version_mismatches: bool,
}
pub fn command(options: Options, noise_level: NoiseLevel) -> Result<()> {
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
}
}
};
let dirs = crate::helpers::app_paths::resolve_dirs();
let mut built_application = super::build::run(
super::build::Options {
debug: !options.release,
targets: Some(vec![]), /* skips IPA build since there's no target */
features: Vec::new(),
config: options.config.clone(),
build_number: None,
open: options.open,
ci: false,
export_method: None,
args: options.args,
ignore_version_mismatches: options.ignore_version_mismatches,
no_sign: false,
archive_only: false,
target_device: device.as_ref().map(|d| TargetDevice {
id: d.id().to_string(),
name: d.name().to_string(),
}),
},
noise_level,
&dirs,
)?;
let mut tauri_config = get_tauri_config(
tauri_utils::platform::Target::Ios,
&options.config.iter().map(|c| &c.0).collect::<Vec<_>>(),
dirs.tauri,
)?;
// options.open is handled by the build command
// so all we need to do here is run the app on the selected device
if let Some(device) = device {
let runner = move |_tauri_config: &ConfigMetadata| {
device
.run(
&built_application.config,
&env,
noise_level,
false, // do not quit on app exit
if !options.release {
Profile::Debug
} else {
Profile::Release
},
)
.map(|c| Box::new(DevChild::new(c)) as Box<dyn DevProcess + Send>)
.context("failed to run iOS app")
};
if options.no_watch {
runner(&tauri_config)?;
} else {
built_application.interface.watch(
&mut tauri_config,
WatcherOptions {
config: options.config,
additional_watch_folders: options.additional_watch_folders,
},
runner,
&dirs,
)?;
}
}
Ok(())
}

View File

@@ -0,0 +1,354 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use super::{MobileTarget, 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, Options as InterfaceOptions},
mobile::ios::LIB_OUTPUT_FILE_NAME,
};
use cargo_mobile2::{apple::target::Target, opts::Profile, target::TargetTrait};
use clap::{ArgAction, Parser};
use object::{Object, ObjectSymbol};
use std::{
collections::HashMap,
env::{current_dir, set_current_dir, var, var_os},
ffi::OsStr,
fs::read_to_string,
io::Read,
path::{Path, PathBuf},
};
#[derive(Debug, Parser)]
pub struct Options {
/// Value of `PLATFORM_DISPLAY_NAME` env var
#[clap(long)]
platform: String,
/// Value of `SDKROOT` env var
#[clap(long)]
sdk_root: PathBuf,
/// Value of `FRAMEWORK_SEARCH_PATHS` env var
#[clap(long, action = ArgAction::Append, num_args(0..))]
framework_search_paths: Vec<String>,
/// Value of `GCC_PREPROCESSOR_DEFINITIONS` env var
#[clap(long, action = ArgAction::Append, num_args(0..))]
gcc_preprocessor_definitions: Vec<String>,
/// Value of `HEADER_SEARCH_PATHS` env var
#[clap(long, action = ArgAction::Append, num_args(0..))]
header_search_paths: Vec<String>,
/// Value of `CONFIGURATION` env var
#[clap(long)]
configuration: String,
/// Value of `FORCE_COLOR` env var
#[clap(long)]
force_color: bool,
/// Value of `ARCHS` env var
#[clap(index = 1, required = true)]
arches: Vec<String>,
}
pub fn command(options: Options) -> Result<()> {
fn macos_from_platform(platform: &str) -> bool {
platform == "macOS"
}
fn profile_from_configuration(configuration: &str) -> Profile {
if configuration == "release" {
Profile::Release
} else {
Profile::Debug
}
}
let process_path = std::env::current_exe().unwrap_or_default();
// `xcode-script` is ran from the `gen/apple` folder when not using NPM/yarn/pnpm/deno.
// so we must change working directory to the src-tauri folder to resolve the tauri dir
// additionally, bun@<1.2 does not modify the current working directory, so it is also runs this script from `gen/apple`
// bun@>1.2 now actually moves the CWD to the package root so we shouldn't modify CWD in that case
// see https://bun.sh/blog/bun-v1.2#bun-run-uses-the-correct-directory
if (var_os("npm_lifecycle_event").is_none()
&& var_os("PNPM_PACKAGE_NAME").is_none()
&& process_path.file_stem().unwrap_or_default() != "deno")
|| var("npm_config_user_agent")
.is_ok_and(|agent| agent.starts_with("bun/1.0") || agent.starts_with("bun/1.1"))
{
set_current_dir(
current_dir()
.context("failed to resolve current directory")?
.parent()
.unwrap()
.parent()
.unwrap(),
)
.unwrap();
}
let dirs = crate::helpers::app_paths::resolve_dirs();
let profile = profile_from_configuration(&options.configuration);
let macos = macos_from_platform(&options.platform);
let mut tauri_config = get_tauri_config(tauri_utils::platform::Target::Ios, &[], dirs.tauri)?;
let cli_options = read_options(&tauri_config);
if !cli_options.config.is_empty() {
// reload config with merges from the ios 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::Ios,
&tauri_config,
&AppInterface::new(&tauri_config, None, dirs.tauri)?,
dirs.tauri,
),
&tauri_config,
&[],
&cli_options,
dirs.tauri,
)?;
ensure_init(
&tauri_config,
config.app(),
config.project_dir(),
MobileTarget::Ios,
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()
.context("failed to load iOS environment")?
.explicit_env_vars(cli_options.vars);
if !options.sdk_root.is_dir() {
crate::error::bail!(
"SDK root provided by Xcode was invalid. {} doesn't exist or isn't a directory",
options.sdk_root.display(),
);
}
let include_dir = options.sdk_root.join("usr/include");
if !include_dir.is_dir() {
crate::error::bail!(
"Include dir was invalid. {} doesn't exist or isn't a directory",
include_dir.display()
);
}
// Host flags that are used by build scripts
let macos_isysroot = {
let macos_sdk_root = options
.sdk_root
.join("../../../../MacOSX.platform/Developer/SDKs/MacOSX.sdk");
if !macos_sdk_root.is_dir() {
crate::error::bail!("Invalid SDK root {}", macos_sdk_root.display());
}
format!("-isysroot {}", macos_sdk_root.display())
};
let mut host_env = HashMap::<&str, &OsStr>::new();
host_env.insert("RUST_BACKTRACE", "1".as_ref());
host_env.insert("CFLAGS_x86_64_apple_darwin", macos_isysroot.as_ref());
host_env.insert("CXXFLAGS_x86_64_apple_darwin", macos_isysroot.as_ref());
host_env.insert(
"OBJC_INCLUDE_PATH_x86_64_apple_darwin",
include_dir.as_os_str(),
);
let framework_search_paths = options.framework_search_paths.join(" ");
host_env.insert("FRAMEWORK_SEARCH_PATHS", framework_search_paths.as_ref());
let gcc_preprocessor_definitions = options.gcc_preprocessor_definitions.join(" ");
host_env.insert(
"GCC_PREPROCESSOR_DEFINITIONS",
gcc_preprocessor_definitions.as_ref(),
);
let header_search_paths = options.header_search_paths.join(" ");
host_env.insert("HEADER_SEARCH_PATHS", header_search_paths.as_ref());
let macos_target = Target::macos();
let isysroot = format!("-isysroot {}", options.sdk_root.display());
let simulator =
options.platform == "iOS Simulator" || options.arches.contains(&"Simulator".to_string());
let arches = if simulator {
// when compiling for the simulator, we don't need to build other targets
vec![
if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"x86_64"
}
.to_string(),
]
} else {
options.arches
};
let installed_targets =
crate::interface::rust::installation::installed_targets().unwrap_or_default();
for arch in arches {
// Set target-specific flags
let (env_triple, rust_triple) = match arch.as_str() {
"arm64" if !simulator => ("aarch64_apple_ios", "aarch64-apple-ios"),
"arm64" if simulator => ("aarch64_apple_ios_sim", "aarch64-apple-ios-sim"),
"x86_64" => ("x86_64_apple_ios", "x86_64-apple-ios"),
_ => {
crate::error::bail!("Arch specified by Xcode was invalid. {arch} isn't a known arch")
}
};
let interface = AppInterface::new(&tauri_config, Some(rust_triple.into()), dirs.tauri)?;
let cflags = format!("CFLAGS_{env_triple}");
let cxxflags = format!("CFLAGS_{env_triple}");
let objc_include_path = format!("OBJC_INCLUDE_PATH_{env_triple}");
let mut target_env = host_env.clone();
target_env.insert(cflags.as_ref(), isysroot.as_ref());
target_env.insert(cxxflags.as_ref(), isysroot.as_ref());
target_env.insert(objc_include_path.as_ref(), include_dir.as_ref());
let target = if macos {
&macos_target
} else {
Target::for_arch(if arch == "arm64" && simulator {
"arm64-sim"
} else {
&arch
})
.with_context(|| format!("Arch specified by Xcode was invalid. {arch} isn't a known arch"))?
};
if !installed_targets.contains(&rust_triple.into()) {
log::info!("Installing target {}", target.triple());
target.install().map_err(|error| Error::CommandFailed {
command: "rustup target add".to_string(),
error,
})?;
}
target
.compile_lib(
&config,
&metadata,
cli_options.noise_level,
true,
profile,
&env,
target_env,
)
.context("failed to compile iOS app")?;
let out_dir = interface.app_settings().out_dir(
&InterfaceOptions {
debug: matches!(profile, Profile::Debug),
target: Some(rust_triple.into()),
..Default::default()
},
dirs.tauri,
)?;
let lib_path = out_dir.join(format!("lib{}.a", config.app().lib_name()));
if !lib_path.exists() {
crate::error::bail!(
"Library not found at {}. Make sure your Cargo.toml file has a [lib] block with `crate-type = [\"staticlib\", \"cdylib\", \"lib\"]`",
lib_path.display()
);
}
validate_lib(&lib_path)?;
let project_dir = config.project_dir();
let externals_lib_dir = project_dir.join(format!("Externals/{arch}/{}", profile.as_str()));
std::fs::create_dir_all(&externals_lib_dir).fs_context(
"failed to create externals lib directory",
externals_lib_dir.clone(),
)?;
// backwards compatible lib output file name
let uses_new_lib_output_file_name = {
let pbxproj_path = project_dir
.join(format!("{}.xcodeproj", config.app().name()))
.join("project.pbxproj");
let pbxproj_contents = read_to_string(&pbxproj_path)
.fs_context("failed to read project.pbxproj file", pbxproj_path)?;
pbxproj_contents.contains(LIB_OUTPUT_FILE_NAME)
};
let lib_output_file_name = if uses_new_lib_output_file_name {
LIB_OUTPUT_FILE_NAME.to_string()
} else {
format!("lib{}.a", config.app().lib_name())
};
std::fs::copy(&lib_path, externals_lib_dir.join(lib_output_file_name)).fs_context(
"failed to copy mobile lib file to Externals directory",
lib_path.to_path_buf(),
)?;
}
Ok(())
}
fn validate_lib(path: &Path) -> Result<()> {
let mut archive = ar::Archive::new(
std::fs::File::open(path).fs_context("failed to open mobile lib file", path.to_path_buf())?,
);
// Iterate over all entries in the archive:
while let Some(entry) = archive.next_entry() {
let Ok(mut entry) = entry else {
continue;
};
let mut obj_bytes = Vec::new();
entry
.read_to_end(&mut obj_bytes)
.fs_context("failed to read mobile lib entry", path.to_path_buf())?;
let file = object::File::parse(&*obj_bytes)
.map_err(std::io::Error::other)
.fs_context("failed to parse mobile lib entry", path.to_path_buf())?;
for symbol in file.symbols() {
let Ok(name) = symbol.name() else {
continue;
};
if name.contains("start_app") {
return Ok(());
}
}
}
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()
)
}

View File

@@ -0,0 +1,642 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::{
ConfigValue, Error, Result,
error::{Context, ErrorExt},
helpers::config::{Config as TauriConfig, ConfigMetadata, reload_config},
interface::{AppInterface, AppSettings, DevProcess, Options as InterfaceOptions},
};
use heck::ToSnekCase;
use jsonrpsee::core::client::{Client, ClientBuilder, ClientT};
use jsonrpsee::server::{RpcModule, ServerBuilder, ServerHandle};
use jsonrpsee_client_transport::ws::WsTransportClientBuilder;
use jsonrpsee_core::rpc_params;
use serde::{Deserialize, Serialize};
use cargo_mobile2::{
ChildHandle,
config::app::{App, Raw as RawAppConfig},
env::Error as EnvError,
opts::{NoiseLevel, Profile},
};
use std::{
collections::HashMap,
env::{set_var, temp_dir},
ffi::OsString,
fmt::{Display, Write},
fs::{read_to_string, write},
net::{AddrParseError, IpAddr, Ipv4Addr, SocketAddr},
path::{Path, PathBuf},
process::{ExitStatus, exit},
str::FromStr,
sync::{
Arc, OnceLock,
atomic::{AtomicBool, Ordering},
},
};
use tokio::runtime::Runtime;
#[cfg(not(windows))]
use cargo_mobile2::env::Env;
#[cfg(windows)]
use cargo_mobile2::os::Env;
pub mod android;
mod init;
#[cfg(target_os = "macos")]
pub mod ios;
const MIN_DEVICE_MATCH_SCORE: isize = 0;
#[derive(Clone)]
pub struct DevChild {
child: Arc<ChildHandle>,
manually_killed_process: Arc<AtomicBool>,
}
impl DevChild {
fn new(handle: ChildHandle) -> Self {
Self {
child: Arc::new(handle),
manually_killed_process: Default::default(),
}
}
}
impl DevProcess for DevChild {
fn kill(&self) -> std::io::Result<()> {
self.child.kill()?;
self.manually_killed_process.store(true, Ordering::SeqCst);
Ok(())
}
fn wait(&self) -> std::io::Result<ExitStatus> {
self.child.wait().map(|o| o.status)
}
fn manually_killed_process(&self) -> bool {
self.manually_killed_process.load(Ordering::SeqCst)
}
}
#[derive(PartialEq, Eq, Copy, Clone)]
pub enum Target {
Android,
#[cfg(target_os = "macos")]
Ios,
}
impl Target {
fn ide_name(&self) -> &'static str {
match self {
Self::Android => "Android Studio",
#[cfg(target_os = "macos")]
Self::Ios => "Xcode",
}
}
fn command_name(&self) -> &'static str {
match self {
Self::Android => "android",
#[cfg(target_os = "macos")]
Self::Ios => "ios",
}
}
fn ide_build_script_name(&self) -> &'static str {
match self {
Self::Android => "android-studio-script",
#[cfg(target_os = "macos")]
Self::Ios => "xcode-script",
}
}
fn platform_target(&self) -> tauri_utils::platform::Target {
match self {
Self::Android => tauri_utils::platform::Target::Android,
#[cfg(target_os = "macos")]
Self::Ios => tauri_utils::platform::Target::Ios,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TargetDevice {
id: String,
name: String,
}
#[derive(Debug, Clone)]
pub struct DevHost(Option<Option<IpAddr>>);
impl FromStr for DevHost {
type Err = AddrParseError;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
if s.is_empty() || s == "<public network address>" {
Ok(Self(Some(None)))
} else if s == "<none>" {
Ok(Self(None))
} else {
IpAddr::from_str(s).map(|addr| Self(Some(Some(addr))))
}
}
}
impl Display for DevHost {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.0 {
Some(None) => write!(f, "<public network address>"),
Some(Some(addr)) => write!(f, "{addr}"),
None => write!(f, "<none>"),
}
}
}
impl Default for DevHost {
fn default() -> Self {
// on Windows we want to force using the public network address for the development server
// because the adb port forwarding does not work well
if cfg!(windows) {
Self(Some(None))
} else {
Self(None)
}
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct CliOptions {
pub dev: bool,
pub features: Vec<String>,
pub args: Vec<String>,
pub noise_level: NoiseLevel,
pub vars: HashMap<String, OsString>,
pub config: Vec<ConfigValue>,
pub target_device: Option<TargetDevice>,
}
fn local_ip_address(force: bool) -> &'static IpAddr {
static LOCAL_IP: OnceLock<IpAddr> = OnceLock::new();
LOCAL_IP.get_or_init(|| {
let prompt_for_ip = || {
let addresses: Vec<IpAddr> = local_ip_address::list_afinet_netifas()
.expect("failed to list networks")
.into_iter()
.map(|(_, ipaddr)| ipaddr)
.filter(|ipaddr| match ipaddr {
IpAddr::V4(i) => i != &Ipv4Addr::LOCALHOST,
IpAddr::V6(i) => i.to_string().ends_with("::2"),
})
.collect();
match addresses.as_slice() {
[] => panic!("No external IP detected."),
[ipaddr] => *ipaddr,
_ => {
let selected = dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(
"Failed to detect external IP, What IP should we use to access your development server?",
)
.items(&addresses)
.default(0)
.interact()
.expect("failed to select external IP");
*addresses.get(selected).unwrap()
}
}
};
let ip = if force {
prompt_for_ip()
} else {
local_ip_address::local_ip().unwrap_or_else(|_| prompt_for_ip())
};
log::info!("Using {ip} to access the development server.");
ip
})
}
struct DevUrlConfig {
no_dev_server_wait: bool,
}
fn use_network_address_for_dev_url(
config: &mut ConfigMetadata,
dev_options: &mut crate::dev::Options,
force_ip_prompt: bool,
tauri_dir: &Path,
) -> crate::Result<DevUrlConfig> {
let mut dev_url = config.build.dev_url.clone();
let ip = if let Some(url) = &mut dev_url {
let localhost = match url.host() {
Some(url::Host::Domain(d)) => d == "localhost",
Some(url::Host::Ipv4(i)) => i == Ipv4Addr::LOCALHOST || i == Ipv4Addr::UNSPECIFIED,
_ => false,
};
if localhost {
let ip = dev_options
.host
.unwrap_or_else(|| *local_ip_address(force_ip_prompt));
log::info!(
"Replacing devUrl host with {ip}. {}.",
"If your frontend is not listening on that address, try configuring your development server to use the `TAURI_DEV_HOST` environment variable or 0.0.0.0 as host"
);
let url_str = format!(
"{}://{}{}",
url.scheme(),
SocketAddr::new(ip, url.port_or_known_default().unwrap()),
url.path()
);
*url =
url::Url::parse(&url_str).with_context(|| format!("failed to parse URL: {url_str}"))?;
dev_options
.config
.push(crate::ConfigValue(serde_json::json!({
"build": {
"devUrl": url
}
})));
reload_config(
config,
&dev_options
.config
.iter()
.map(|conf| &conf.0)
.collect::<Vec<_>>(),
tauri_dir,
)?;
Some(ip)
} else {
None
}
} else if !dev_options.no_dev_server {
let ip = dev_options
.host
.unwrap_or_else(|| *local_ip_address(force_ip_prompt));
dev_options.host.replace(ip);
Some(ip)
} else {
None
};
let mut dev_url_config = DevUrlConfig {
no_dev_server_wait: false,
};
if let Some(ip) = ip {
unsafe { std::env::set_var("TAURI_DEV_HOST", ip.to_string()) };
unsafe { std::env::set_var("TRUNK_SERVE_ADDRESS", ip.to_string()) };
if ip.is_ipv6() {
// in this case we can't ping the server for some reason
dev_url_config.no_dev_server_wait = true;
}
}
Ok(dev_url_config)
}
fn env_vars() -> HashMap<String, OsString> {
let mut vars = HashMap::new();
vars.insert("RUST_LOG_STYLE".into(), "always".into());
for (k, v) in std::env::vars_os() {
let k = k.to_string_lossy();
if (k.starts_with("TAURI")
&& k != "TAURI_SIGNING_PRIVATE_KEY"
&& k != "TAURI_SIGNING_PRIVATE_KEY_PASSWORD")
|| k.starts_with("WRY")
|| k.starts_with("CARGO_")
|| k.starts_with("RUST_")
|| k == "TMPDIR"
|| k == "PATH"
{
vars.insert(k.into_owned(), v);
}
}
vars
}
fn env() -> std::result::Result<Env, EnvError> {
let env = Env::new()?.explicit_env_vars(env_vars());
Ok(env)
}
pub struct OptionsHandle(#[allow(unused)] Runtime, #[allow(unused)] ServerHandle);
/// Writes CLI options to be used later on the Xcode and Android Studio build commands
pub fn write_options(
config: &ConfigMetadata,
mut options: CliOptions,
) -> crate::Result<OptionsHandle> {
options.vars.extend(env_vars());
let runtime = Runtime::new().unwrap();
let r: crate::Result<(ServerHandle, SocketAddr)> = runtime.block_on(async move {
let server = ServerBuilder::default()
.build("127.0.0.1:0")
.await
.context("failed to build WebSocket server")?;
let addr = server.local_addr().context("failed to get local address")?;
let mut module = RpcModule::new(());
module
.register_method("options", move |_, _, _| Some(options.clone()))
.context("failed to register options method")?;
let handle = server.start(module);
Ok((handle, addr))
});
let (handle, addr) = r?;
let server_addr_path = temp_dir().join(format!(
"{}-server-addr",
config
.original_identifier()
.context("app configuration is missing an identifier")?
));
write(&server_addr_path, addr.to_string())
.fs_context("failed to write server address file", server_addr_path)?;
Ok(OptionsHandle(runtime, handle))
}
fn read_options(config: &ConfigMetadata) -> CliOptions {
let runtime = tokio::runtime::Runtime::new().unwrap();
let options = runtime
.block_on(async move {
let addr_path = temp_dir().join(format!(
"{}-server-addr",
config
.original_identifier()
.context("app configuration is missing an identifier")?
));
let (tx, rx) = WsTransportClientBuilder::default()
.build(
format!(
"ws://{}",
read_to_string(&addr_path).unwrap_or_else(|e| panic!(
"failed to read missing addr file {}: {e}",
addr_path.display()
))
)
.parse()
.unwrap(),
)
.await
.context("failed to build WebSocket client")?;
let client: Client = ClientBuilder::default().build_with_tokio(tx, rx);
let options: CliOptions = client
.request("options", rpc_params![])
.await
.context("failed to request options")?;
Ok::<CliOptions, Error>(options)
})
.expect("failed to read CLI options");
for (k, v) in &options.vars {
unsafe { set_var(k, v) };
}
options
}
pub fn get_app(
target: Target,
config: &TauriConfig,
interface: &AppInterface,
tauri_dir: &Path,
) -> App {
let identifier = match target {
Target::Android => config.identifier.replace('-', "_"),
#[cfg(target_os = "macos")]
Target::Ios => config.identifier.replace('_', "-"),
};
if identifier.is_empty() {
log::error!("Bundle identifier set in `tauri.conf.json > identifier` cannot be empty");
exit(1);
}
let app_name = interface
.app_settings()
.app_name()
.unwrap_or_else(|| "app".into());
let lib_name = interface
.app_settings()
.lib_name()
.unwrap_or_else(|| app_name.to_snek_case());
if config.product_name.is_none() {
log::warn!(
"`productName` is not set in the Tauri configuration. Using `{app_name}` as the app name."
);
}
let raw = RawAppConfig {
name: app_name,
lib_name: Some(lib_name),
stylized_name: config.product_name.clone(),
identifier,
asset_dir: None,
template_pack: None,
};
let app_settings = interface.app_settings();
let tauri_dir = tauri_dir.to_path_buf();
App::from_raw(tauri_dir.to_path_buf(), raw)
.unwrap()
.with_target_dir_resolver(move |target, profile| {
app_settings
.out_dir(
&InterfaceOptions {
debug: matches!(profile, Profile::Debug),
target: Some(target.into()),
..Default::default()
},
&tauri_dir,
)
.expect("failed to resolve target directory")
})
}
#[allow(unused_variables)]
fn ensure_init(
tauri_config: &ConfigMetadata,
app: &App,
project_dir: PathBuf,
target: Target,
noninteractive: bool,
) -> Result<()> {
if !project_dir.exists() {
crate::error::bail!(
"{} project directory {} doesn't exist. Please run `tauri {} init` and try again.",
target.ide_name(),
project_dir.display(),
target.command_name(),
)
}
let mut project_outdated_reasons = Vec::new();
match target {
Target::Android => {
let java_folder = project_dir
.join("app/src/main/java")
.join(tauri_config.identifier.replace('.', "/").replace('-', "_"));
if java_folder.exists() {
ensure_gradlew(&project_dir)?;
} else {
project_outdated_reasons
.push("you have modified your \"identifier\" in the Tauri configuration");
}
}
#[cfg(target_os = "macos")]
Target::Ios => {
let xcodeproj_path = crate::helpers::fs::find_in_directory(&project_dir, "*.xcodeproj")
.with_context(|| format!("failed to locate xcodeproj in {}", project_dir.display()))?;
let xcodeproj_name = xcodeproj_path.file_stem().unwrap().to_str().unwrap();
if xcodeproj_name != app.name() {
let rename_targets = vec![
// first rename the entitlements
(
format!("{xcodeproj_name}_iOS/{xcodeproj_name}_iOS.entitlements"),
format!("{xcodeproj_name}_iOS/{}_iOS.entitlements", app.name()),
),
// then the scheme folder
(
format!("{xcodeproj_name}_iOS"),
format!("{}_iOS", app.name()),
),
(
format!("{xcodeproj_name}.xcodeproj"),
format!("{}.xcodeproj", app.name()),
),
];
let rename_info = rename_targets
.iter()
.map(|(from, to)| format!("- {from} to {to}"))
.collect::<Vec<_>>()
.join("\n");
log::error!(
"you have modified your package name from {current_project_name} to {new_project_name}\nWe need to apply the name change to the Xcode project, renaming:\n{rename_info}",
new_project_name = app.name(),
current_project_name = xcodeproj_name,
);
if noninteractive {
project_outdated_reasons
.push("you have modified your [lib.name] or [package.name] in the Cargo.toml file");
} else {
let confirm = crate::helpers::prompts::confirm(
"Do you want to apply the name change to the Xcode project?",
Some(true),
)
.unwrap_or_default();
if confirm {
for (from, to) in rename_targets {
std::fs::rename(project_dir.join(&from), project_dir.join(&to))
.with_context(|| format!("failed to rename {from} to {to}"))?;
}
// update scheme name in pbxproj
// identifier / product name are synchronized by the dev/build commands
let pbxproj_path =
project_dir.join(format!("{}.xcodeproj/project.pbxproj", app.name()));
let pbxproj_contents = std::fs::read_to_string(&pbxproj_path)
.with_context(|| format!("failed to read {}", pbxproj_path.display()))?;
std::fs::write(
&pbxproj_path,
pbxproj_contents.replace(
&format!("{xcodeproj_name}_iOS"),
&format!("{}_iOS", app.name()),
),
)
.with_context(|| format!("failed to write {}", pbxproj_path.display()))?;
} else {
project_outdated_reasons
.push("you have modified your [lib.name] or [package.name] in the Cargo.toml file");
}
}
}
// note: pbxproj is synchronied by the dev/build commands
}
}
if !project_outdated_reasons.is_empty() {
let reason = project_outdated_reasons.join(" and ");
crate::error::bail!(
"{} project directory is outdated because {reason}. Please delete {}, run `tauri {} init` and try again.",
target.ide_name(),
project_dir.display(),
target.command_name(),
)
}
Ok(())
}
fn ensure_gradlew(project_dir: &std::path::Path) -> Result<()> {
let gradlew_path = project_dir.join("gradlew");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = gradlew_path.metadata() {
let mut permissions = metadata.permissions();
let is_executable = permissions.mode() & 0o111 != 0;
if !is_executable {
permissions.set_mode(permissions.mode() | 0o111);
std::fs::set_permissions(&gradlew_path, permissions)
.fs_context("failed to mark gradlew as executable", &gradlew_path)?;
}
}
}
// A gradlew with CRLF line endings cannot run: sh fails with
// "/usr/bin/env: 'sh\r': No such file or directory" or similar, which
// also happens under Git Bash on Windows, so the rewrite runs on all
// platforms (https://github.com/tauri-apps/tauri/pull/16017). Windows
// builds invoke gradlew.bat, so there a gradlew that cannot be
// rewritten only draws a warning; on unix the error is returned.
if gradlew_path.exists() {
let result = std::fs::read_to_string(&gradlew_path)
.fs_context("failed to read gradlew", &gradlew_path)
.and_then(|contents| {
if contents.contains("\r\n") {
std::fs::write(&gradlew_path, contents.replace("\r\n", "\n"))
.fs_context("failed to replace gradlew CRLF with LF", &gradlew_path)
} else {
Ok(())
}
});
#[cfg(unix)]
result?;
#[cfg(not(unix))]
if let Err(error) = result {
log::warn!("failed to normalize gradlew line endings: {error}");
}
}
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);
}
}

View File

@@ -0,0 +1,141 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::{
Result,
error::Context,
helpers::{prompts, template},
};
use clap::{Parser, Subcommand};
use handlebars::Handlebars;
use std::{
collections::BTreeMap,
env::current_dir,
ffi::OsStr,
path::{Component, PathBuf},
};
#[derive(Parser)]
#[clap(
author,
version,
about = "Manage the Android project for a Tauri plugin",
subcommand_required(true),
arg_required_else_help(true)
)]
pub struct Cli {
#[clap(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Init(InitOptions),
}
#[derive(Debug, Parser)]
#[clap(about = "Initializes the Android project for an existing Tauri plugin")]
pub struct InitOptions {
/// Name of your Tauri plugin. Must match the current plugin's name.
/// If not specified, it will be inferred from the current directory.
plugin_name: Option<String>,
/// The output directory.
#[clap(short, long)]
#[clap(default_value_t = current_dir().expect("failed to read cwd").to_string_lossy().into_owned())]
out_dir: String,
}
pub fn command(cli: Cli) -> Result<()> {
match cli.command {
Commands::Init(options) => {
let plugin_name = match options.plugin_name {
None => super::infer_plugin_name(
std::env::current_dir().context("failed to get current directory")?,
)?,
Some(name) => name,
};
let out_dir = PathBuf::from(options.out_dir);
if out_dir.join("android").exists() {
crate::error::bail!("Android folder already exists");
}
let plugin_id = prompts::input(
"What should be the Android Package ID for your plugin?",
Some(format!("com.plugin.{plugin_name}")),
false,
false,
)?
.unwrap();
let handlebars = Handlebars::new();
let mut data = BTreeMap::new();
super::init::plugin_name_data(&mut data, &plugin_name);
data.insert("android_package_id", handlebars::to_json(&plugin_id));
let mut created_dirs = Vec::new();
template::render_with_generator(
&handlebars,
&data,
&super::init::TEMPLATE_DIR,
&out_dir,
&mut |path| {
let mut components = path.components();
let root = components.next().unwrap();
if let Component::Normal(component) = root {
if component == OsStr::new("android") {
return super::init::generate_android_out_file(
&path,
&out_dir,
&plugin_id.replace('.', "/"),
&mut created_dirs,
);
}
}
Ok(None)
},
)?;
let metadata = super::init::crates_metadata()?;
let cargo_toml_addition = format!(
r#"
[build-dependencies]
tauri-build = "{}"
"#,
metadata.tauri_build
);
let build_file = super::init::TEMPLATE_DIR
.get_file("build.rs")
.unwrap()
.contents_utf8()
.unwrap();
let init_fn = format!(
r#"
pub fn init<R: Runtime>() -> TauriPlugin<R> {{
Builder::new("{plugin_name}")
.setup(|app, api| {{
#[cfg(target_os = "android")]
let handle = api.register_android_plugin("{plugin_id}", "ExamplePlugin")?;
Ok(())
}})
.build()
}}
"#
);
log::info!("Android project added");
println!("You must add the following to the Cargo.toml file:\n{cargo_toml_addition}",);
println!("You must add the following code to the build.rs file:\n\n{build_file}",);
println!(
"Your plugin's init function under src/lib.rs must initialize the Android plugin:\n{init_fn}"
);
}
}
Ok(())
}

View File

@@ -0,0 +1,329 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use super::PluginIosFramework;
use crate::Result;
use crate::{
VersionMetadata,
error::{Context, ErrorExt},
helpers::{prompts, resolve_tauri_path, template},
};
use clap::Parser;
use handlebars::{Handlebars, to_json};
use heck::{ToKebabCase, ToPascalCase, ToSnakeCase};
use include_dir::{Dir, include_dir};
use std::ffi::{OsStr, OsString};
use std::{
collections::BTreeMap,
env::current_dir,
fs::{File, OpenOptions, create_dir_all, remove_dir_all},
path::{Component, Path, PathBuf},
};
pub const TEMPLATE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/templates/plugin");
#[derive(Debug, Parser)]
#[clap(about = "Initialize a Tauri plugin project on an existing directory")]
pub struct Options {
/// Name of your Tauri plugin.
/// If not specified, it will be inferred from the current directory.
pub(crate) plugin_name: Option<String>,
/// Initializes a Tauri plugin without the TypeScript API
#[clap(long)]
pub(crate) no_api: bool,
/// Initialize without an example project.
#[clap(long)]
pub(crate) no_example: bool,
/// Set target directory for init
#[clap(short, long)]
#[clap(default_value_t = current_dir().expect("failed to read cwd").display().to_string())]
pub(crate) directory: String,
/// Author name
#[clap(short, long)]
pub(crate) author: Option<String>,
/// Whether to initialize an Android project for the plugin.
#[clap(long)]
pub(crate) android: bool,
/// Whether to initialize an iOS project for the plugin.
#[clap(long)]
pub(crate) ios: bool,
/// Whether to initialize Android and iOS projects for the plugin.
#[clap(long)]
pub(crate) mobile: bool,
/// Type of framework to use for the iOS project.
#[clap(long)]
#[clap(default_value_t = PluginIosFramework::default())]
pub(crate) ios_framework: PluginIosFramework,
/// Generate github workflows
#[clap(long)]
pub(crate) github_workflows: bool,
/// Initializes a Tauri core plugin (internal usage)
#[clap(long, hide(true))]
pub(crate) tauri: bool,
/// Path of the Tauri project to use (relative to the cwd)
#[clap(short, long)]
pub(crate) tauri_path: Option<PathBuf>,
}
impl Options {
fn load(&mut self) {
if self.author.is_none() {
self.author.replace(if self.tauri {
"Tauri Programme within The Commons Conservancy".into()
} else {
"You".into()
});
}
}
}
pub fn command(mut options: Options) -> Result<()> {
options.load();
let plugin_name = match options.plugin_name {
None => super::infer_plugin_name(&options.directory)?,
Some(name) => name,
};
let template_target_path = PathBuf::from(options.directory);
let metadata = crates_metadata()?;
if std::fs::read_dir(&template_target_path)
.fs_context(
"failed to read target directory",
template_target_path.clone(),
)?
.count()
> 0
{
log::warn!("Plugin dir ({:?}) not empty.", template_target_path);
} else {
let (tauri_dep, tauri_example_dep, tauri_build_dep, tauri_plugin_dep) =
if let Some(tauri_path) = options.tauri_path {
(
format!(
r#"{{ path = {:?} }}"#,
resolve_tauri_path(&tauri_path, "crates/tauri")
),
format!(
r#"{{ path = {:?} }}"#,
resolve_tauri_path(&tauri_path, "crates/tauri")
),
format!(
"{{ path = {:?}, default-features = false }}",
resolve_tauri_path(&tauri_path, "crates/tauri-build")
),
format!(
r#"{{ path = {:?}, features = ["build"] }}"#,
resolve_tauri_path(&tauri_path, "crates/tauri-plugin")
),
)
} else {
(
format!(r#"{{ version = "{}" }}"#, metadata.tauri),
format!(r#"{{ version = "{}" }}"#, metadata.tauri),
format!(
r#"{{ version = "{}", default-features = false }}"#,
metadata.tauri_build
),
format!(
r#"{{ version = "{}", features = ["build"] }}"#,
metadata.tauri_plugin
),
)
};
let _ = remove_dir_all(&template_target_path);
let mut handlebars = Handlebars::new();
handlebars.register_escape_fn(handlebars::no_escape);
let mut data = BTreeMap::new();
plugin_name_data(&mut data, &plugin_name);
data.insert("tauri_dep", to_json(tauri_dep));
data.insert("tauri_example_dep", to_json(tauri_example_dep));
data.insert("tauri_build_dep", to_json(tauri_build_dep));
data.insert("tauri_plugin_dep", to_json(tauri_plugin_dep));
data.insert("author", to_json(options.author));
if options.tauri {
data.insert(
"license_header",
to_json(
"// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT\n\n"
.replace(" ", "")
.replace(" //", "//"),
),
);
}
let plugin_id = if options.android || options.mobile {
let plugin_id = prompts::input(
"What should be the Android Package ID for your plugin?",
Some(format!("com.plugin.{plugin_name}")),
false,
false,
)?
.unwrap();
data.insert("android_package_id", to_json(&plugin_id));
Some(plugin_id)
} else {
None
};
let ios_framework = options.ios_framework;
let mut created_dirs = Vec::new();
template::render_with_generator(
&handlebars,
&data,
&TEMPLATE_DIR,
&template_target_path,
&mut |mut path| {
let mut components = path.components();
let root = components.next().unwrap();
if let Component::Normal(component) = root {
match component.to_str().unwrap() {
"__example-api" => {
if options.no_api || options.no_example {
return Ok(None);
} else {
path = Path::new("examples").join(components.collect::<PathBuf>());
}
}
"__example-basic" => {
if options.no_api && !options.no_example {
path = Path::new("examples").join(components.collect::<PathBuf>());
} else {
return Ok(None);
}
}
".github" if !options.github_workflows => return Ok(None),
"android" => {
if options.android || options.mobile {
return generate_android_out_file(
&path,
&template_target_path,
&plugin_id.as_ref().unwrap().replace('.', "/"),
&mut created_dirs,
);
} else {
return Ok(None);
}
}
"ios-spm" | "ios-xcode" if !(options.ios || options.mobile) => return Ok(None),
"ios-spm" if !matches!(ios_framework, PluginIosFramework::Spm) => return Ok(None),
"ios-xcode" if !matches!(ios_framework, PluginIosFramework::Xcode) => return Ok(None),
"ios-spm" | "ios-xcode" => {
let folder_name = components.next().unwrap().as_os_str().to_string_lossy();
let new_folder_name = folder_name.replace("{{ plugin_name }}", &plugin_name);
let new_folder_name = OsString::from(&new_folder_name);
path = [
Component::Normal(OsStr::new("ios")),
Component::Normal(&new_folder_name),
]
.into_iter()
.chain(components)
.collect::<PathBuf>();
}
"guest-js" | "rollup.config.js" | "tsconfig.json" | "package.json"
if options.no_api =>
{
return Ok(None);
}
_ => (),
}
}
let path = template_target_path.join(path);
let parent = path.parent().unwrap().to_path_buf();
if !created_dirs.contains(&parent) {
create_dir_all(&parent)?;
created_dirs.push(parent);
}
File::create(path).map(Some)
},
)
.with_context(|| "failed to render plugin template")?;
}
let permissions_dir = template_target_path.join("permissions");
std::fs::create_dir(&permissions_dir).fs_context(
"failed to create `permissions` directory",
permissions_dir.clone(),
)?;
let default_permissions = r#"[default]
description = "Default permissions for the plugin"
permissions = ["allow-ping"]
"#;
std::fs::write(permissions_dir.join("default.toml"), default_permissions).fs_context(
"failed to write default permissions file",
permissions_dir.join("default.toml"),
)?;
Ok(())
}
pub fn plugin_name_data(data: &mut BTreeMap<&'static str, serde_json::Value>, plugin_name: &str) {
data.insert("plugin_name_original", to_json(plugin_name));
data.insert("plugin_name", to_json(plugin_name.to_kebab_case()));
data.insert(
"plugin_name_snake_case",
to_json(plugin_name.to_snake_case()),
);
data.insert(
"plugin_name_pascal_case",
to_json(plugin_name.to_pascal_case()),
);
}
pub fn crates_metadata() -> Result<VersionMetadata> {
serde_json::from_str::<VersionMetadata>(include_str!("../../metadata-v2.json"))
.context("failed to parse Tauri version metadata")
}
pub fn generate_android_out_file(
path: &Path,
dest: &Path,
package_path: &str,
created_dirs: &mut Vec<PathBuf>,
) -> std::io::Result<Option<File>> {
let mut iter = path.iter();
let root = iter.next().unwrap().to_str().unwrap();
let path = match (root, path.extension().and_then(|o| o.to_str())) {
("src", Some("kt")) => {
let parent = path.parent().unwrap();
let file_name = path.file_name().unwrap();
let out_dir = dest.join(parent).join(package_path);
out_dir.join(file_name)
}
_ => dest.join(path),
};
let parent = path.parent().unwrap().to_path_buf();
if !created_dirs.contains(&parent) {
create_dir_all(&parent)?;
created_dirs.push(parent);
}
let mut options = OpenOptions::new();
options.write(true);
#[cfg(unix)]
if path.file_name().unwrap() == std::ffi::OsStr::new("gradlew") {
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o755);
}
if !path.exists() {
options.create(true).open(path).map(Some)
} else {
Ok(None)
}
}

View File

@@ -0,0 +1,155 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use super::PluginIosFramework;
use crate::{Result, error::Context, helpers::template};
use clap::{Parser, Subcommand};
use handlebars::Handlebars;
use std::{
collections::BTreeMap,
env::current_dir,
ffi::{OsStr, OsString},
fs::{File, create_dir_all},
path::{Component, PathBuf},
};
#[derive(Parser)]
#[clap(
author,
version,
about = "Manage the iOS project for a Tauri plugin",
subcommand_required(true),
arg_required_else_help(true)
)]
pub struct Cli {
#[clap(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Init(InitOptions),
}
#[derive(Debug, Parser)]
#[clap(about = "Initializes the iOS project for an existing Tauri plugin")]
pub struct InitOptions {
/// Name of your Tauri plugin. Must match the current plugin's name.
/// If not specified, it will be inferred from the current directory.
plugin_name: Option<String>,
/// The output directory.
#[clap(short, long)]
#[clap(default_value_t = current_dir().expect("failed to read cwd").to_string_lossy().into_owned())]
out_dir: String,
/// Type of framework to use for the iOS project.
#[clap(long)]
#[clap(default_value_t = PluginIosFramework::default())]
ios_framework: PluginIosFramework,
}
pub fn command(cli: Cli) -> Result<()> {
match cli.command {
Commands::Init(options) => {
let plugin_name = match options.plugin_name {
None => super::infer_plugin_name(
std::env::current_dir().context("failed to get current directory")?,
)?,
Some(name) => name,
};
let out_dir = PathBuf::from(options.out_dir);
if out_dir.join("ios").exists() {
crate::error::bail!("iOS folder already exists");
}
let handlebars = Handlebars::new();
let mut data = BTreeMap::new();
super::init::plugin_name_data(&mut data, &plugin_name);
let ios_folder_name = match options.ios_framework {
PluginIosFramework::Spm => OsStr::new("ios-spm"),
PluginIosFramework::Xcode => OsStr::new("ios-xcode"),
};
let mut created_dirs = Vec::new();
template::render_with_generator(
&handlebars,
&data,
&super::init::TEMPLATE_DIR,
&out_dir,
&mut |path| {
let mut components = path.components();
let root = components.next().unwrap();
if let Component::Normal(component) = root {
if component == ios_folder_name {
let folder_name = components.next().unwrap().as_os_str().to_string_lossy();
let new_folder_name = folder_name.replace("{{ plugin_name }}", &plugin_name);
let new_folder_name = OsString::from(&new_folder_name);
let path = [
Component::Normal(OsStr::new("ios")),
Component::Normal(&new_folder_name),
]
.into_iter()
.chain(components)
.collect::<PathBuf>();
let path = out_dir.join(path);
let parent = path.parent().unwrap().to_path_buf();
if !created_dirs.contains(&parent) {
create_dir_all(&parent)?;
created_dirs.push(parent);
}
return File::create(path).map(Some);
}
}
Ok(None)
},
)?;
let metadata = super::init::crates_metadata()?;
let cargo_toml_addition = format!(
r#"
[build-dependencies]
tauri-build = "{}"
"#,
metadata.tauri_build
);
let build_file = super::init::TEMPLATE_DIR
.get_file("build.rs")
.unwrap()
.contents_utf8()
.unwrap();
let init_fn = format!(
r#"
#[cfg(target_os = "ios")]
tauri::ios_plugin_binding!(init_plugin_{plugin_name});
pub fn init<R: Runtime>() -> TauriPlugin<R> {{
Builder::new("{plugin_name}")
.setup(|app| {{
#[cfg(target_os = "ios")]
app.register_ios_plugin(init_plugin_{plugin_name})?;
Ok(())
}})
.build()
}}
"#,
);
log::info!("iOS project added");
println!("You must add the following to the Cargo.toml file:\n{cargo_toml_addition}",);
println!("You must add the following code to the build.rs file:\n\n{build_file}",);
println!(
"Your plugin's init function under src/lib.rs must initialize the iOS plugin:\n{init_fn}"
);
}
}
Ok(())
}

View File

@@ -0,0 +1,96 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::{fmt::Display, path::Path};
use clap::{Parser, Subcommand, ValueEnum};
use crate::{
Result,
error::{Context, ErrorExt},
};
mod android;
mod init;
mod ios;
mod new;
#[derive(Debug, Clone, ValueEnum, Default)]
pub enum PluginIosFramework {
/// Swift Package Manager project
#[default]
Spm,
/// Xcode project
Xcode,
}
impl Display for PluginIosFramework {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Spm => write!(f, "spm"),
Self::Xcode => write!(f, "xcode"),
}
}
}
#[derive(Parser)]
#[clap(
author,
version,
about = "Manage or create Tauri plugins",
subcommand_required(true),
arg_required_else_help(true)
)]
pub struct Cli {
#[clap(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
New(new::Options),
Init(init::Options),
Android(android::Cli),
Ios(ios::Cli),
}
pub fn command(cli: Cli) -> Result<()> {
match cli.command {
Commands::New(options) => new::command(options)?,
Commands::Init(options) => init::command(options)?,
Commands::Android(cli) => android::command(cli)?,
Commands::Ios(cli) => ios::command(cli)?,
}
Ok(())
}
fn infer_plugin_name<P: AsRef<Path>>(directory: P) -> Result<String> {
let dir = directory.as_ref();
let cargo_toml_path = dir.join("Cargo.toml");
let name = if cargo_toml_path.exists() {
let contents = std::fs::read_to_string(&cargo_toml_path)
.fs_context("failed to read Cargo manifest", cargo_toml_path)?;
let cargo_toml: toml::Value =
toml::from_str(&contents).context("failed to parse Cargo.toml")?;
cargo_toml
.get("package")
.and_then(|v| v.get("name"))
.map(|v| v.as_str().unwrap_or_default())
.unwrap_or_default()
.to_string()
} else {
dir
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
};
Ok(
name
.strip_prefix("tauri-plugin-")
.unwrap_or(&name)
.to_string(),
)
}

View File

@@ -0,0 +1,88 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use super::PluginIosFramework;
use crate::{
Result,
error::{Context, ErrorExt},
};
use clap::Parser;
use std::path::PathBuf;
#[derive(Debug, Parser)]
#[clap(about = "Initializes a new Tauri plugin project")]
pub struct Options {
/// Name of your Tauri plugin
plugin_name: String,
/// Initializes a Tauri plugin without the TypeScript API
#[clap(long)]
no_api: bool,
/// Initialize without an example project.
#[clap(long)]
no_example: bool,
/// Set target directory for init
#[clap(short, long)]
directory: Option<String>,
/// Author name
#[clap(short, long)]
author: Option<String>,
/// Whether to initialize an Android project for the plugin.
#[clap(long)]
android: bool,
/// Whether to initialize an iOS project for the plugin.
#[clap(long)]
ios: bool,
/// Whether to initialize Android and iOS projects for the plugin.
#[clap(long)]
mobile: bool,
/// Type of framework to use for the iOS project.
#[clap(long)]
#[clap(default_value_t = PluginIosFramework::default())]
pub(crate) ios_framework: PluginIosFramework,
/// Generate github workflows
#[clap(long)]
github_workflows: bool,
/// Initializes a Tauri core plugin (internal usage)
#[clap(long, hide(true))]
tauri: bool,
/// Path of the Tauri project to use (relative to the cwd)
#[clap(short, long)]
tauri_path: Option<PathBuf>,
}
impl From<Options> for super::init::Options {
fn from(o: Options) -> Self {
Self {
plugin_name: Some(o.plugin_name),
no_api: o.no_api,
no_example: o.no_example,
directory: o.directory.unwrap(),
author: o.author,
android: o.android,
ios: o.ios,
mobile: o.mobile,
ios_framework: o.ios_framework,
github_workflows: o.github_workflows,
tauri: o.tauri,
tauri_path: o.tauri_path,
}
}
}
pub fn command(mut options: Options) -> Result<()> {
let cwd = std::env::current_dir().context("failed to get current directory")?;
if let Some(dir) = &options.directory {
std::fs::create_dir_all(cwd.join(dir))
.fs_context("failed to create crate directory", cwd.join(dir))?;
} else {
let target = cwd.join(format!("tauri-plugin-{}", options.plugin_name));
std::fs::create_dir_all(&target)
.fs_context("failed to create crate directory", target.clone())?;
options.directory.replace(target.display().to_string());
}
super::init::command(options.into())
}

View File

@@ -0,0 +1,59 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use clap::Parser;
use crate::{
Result, acl,
helpers::{app_paths::resolve_frontend_dir, cargo, npm::PackageManager},
};
#[derive(Debug, Parser)]
#[clap(about = "Remove a tauri plugin from the project")]
pub struct Options {
/// The plugin to remove.
pub plugin: String,
}
pub fn command(options: Options) -> Result<()> {
let dirs = crate::helpers::app_paths::resolve_dirs();
let plugin = options.plugin;
let crate_name = format!("tauri-plugin-{plugin}");
let mut plugins = crate::helpers::plugins::known_plugins();
let metadata = plugins.remove(plugin.as_str()).unwrap_or_default();
let frontend_dir = resolve_frontend_dir();
let target_str = metadata
.desktop_only
.then_some(r#"cfg(not(any(target_os = "android", target_os = "ios")))"#)
.or_else(|| {
metadata
.mobile_only
.then_some(r#"cfg(any(target_os = "android", target_os = "ios"))"#)
});
cargo::uninstall_one(cargo::CargoUninstallOptions {
name: &crate_name,
cwd: Some(dirs.tauri),
target: target_str,
})?;
if !metadata.rust_only {
if let Some(manager) = frontend_dir.map(PackageManager::from_project) {
let npm_name = format!("@tauri-apps/plugin-{plugin}");
manager.remove(&[npm_name], dirs.frontend)?;
}
acl::permission::rm::command(acl::permission::rm::Options {
identifier: format!("{plugin}:*"),
})?;
}
log::info!("Now, you must manually remove the plugin from your Rust code.",);
Ok(())
}

View File

@@ -0,0 +1,77 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::{
Result,
helpers::updater_signature::{generate_key, save_keypair},
};
use clap::Parser;
use std::path::PathBuf;
use tauri_utils::display_path;
#[derive(Debug, Parser)]
#[clap(about = "Generate a new signing key to sign files")]
pub struct Options {
/// Set private key password when signing
#[clap(short, long)]
password: Option<String>,
/// Write private key to a file
#[clap(short, long)]
write_keys: Option<PathBuf>,
/// Overwrite private key even if it exists on the specified path
#[clap(short, long)]
force: bool,
/// Skip prompting for values
#[clap(long, env = "CI")]
ci: bool,
}
pub fn command(mut options: Options) -> Result<()> {
if options.ci && options.password.is_none() {
log::warn!(
"Generating new private key without password. For security reasons, we recommend setting a password instead."
);
options.password.replace("".into());
}
let keypair = generate_key(options.password).expect("Failed to generate key");
if let Some(output_path) = options.write_keys {
let (secret_path, public_path) =
save_keypair(options.force, output_path, &keypair.sk, &keypair.pk)
.expect("Unable to write keypair");
println!();
println!("Your keypair was generated successfully:");
println!("Private: {} (Keep it secret!)", display_path(secret_path));
println!("Public: {}", display_path(public_path));
println!("---------------------------")
} else {
println!();
println!("Your keys were generated successfully!",);
println!();
println!("Private: (Keep it secret!)");
println!("{}", keypair.sk);
println!();
println!("Public:");
println!("{}", keypair.pk);
}
println!();
println!("Environment variables used to sign:");
println!(
"- `TAURI_SIGNING_PRIVATE_KEY`: Your private key. For the `build` and `bundle` command it can be either a string or a path to the file, for the `signer sign` command it must be the literal key string"
);
println!(
"- `TAURI_SIGNING_PRIVATE_KEY_PATH`: Path to your private key file, used by the `signer sign` command"
);
println!(
"- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`: Your private key password (optional if key has no password)"
);
println!();
println!(
"ATTENTION: If you lose your private key OR password, you'll not be able to sign your update package and updates will not work"
);
Ok(())
}

View File

@@ -0,0 +1,36 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::Result;
use clap::{Parser, Subcommand};
mod generate;
mod sign;
#[derive(Parser)]
#[clap(
author,
version,
about = "Generate signing keys for Tauri updater or sign files",
subcommand_required(true),
arg_required_else_help(true)
)]
pub struct Cli {
#[clap(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Sign(sign::Options),
Generate(generate::Options),
}
pub fn command(cli: Cli) -> Result<()> {
match cli.command {
Commands::Sign(options) => sign::command(options)?,
Commands::Generate(options) => generate::command(options)?,
}
Ok(())
}

View File

@@ -0,0 +1,121 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::path::{Path, PathBuf};
use crate::{
Result,
error::Context,
helpers::updater_signature::{secret_key, sign_file},
};
use base64::Engine;
use clap::Parser;
use tauri_utils::display_path;
#[derive(Debug, Parser)]
#[clap(about = "Sign a file")]
pub struct Options {
/// Load the private key from a string
#[clap(
short = 'k',
long,
conflicts_with("private_key_path"),
env = "TAURI_SIGNING_PRIVATE_KEY"
)]
private_key: Option<String>,
/// Load the private key from a file
#[clap(
short = 'f',
long,
conflicts_with("private_key"),
env = "TAURI_SIGNING_PRIVATE_KEY_PATH"
)]
private_key_path: Option<PathBuf>,
/// Set private key password when signing
#[clap(short, long, env = "TAURI_SIGNING_PRIVATE_KEY_PASSWORD")]
password: Option<String>,
/// Bind the signature to this app version.
///
/// The version is embedded in the signature's trusted comment, which is covered by the
/// signature itself. Updaters configured with `requireSignedVersion` reject an update whose
/// manifest announces a different version than the one signed here, which prevents a
/// tampered manifest from pairing a new version number with an older release.
///
/// `tauri build` sets this automatically; pass it when signing updater artifacts by hand.
#[clap(long)]
app_version: Option<String>,
/// Sign the specified file
file: PathBuf,
}
// Backwards compatibility with old env vars
// TODO: remove in v3.0
fn backward_env_vars(mut options: Options) -> Options {
let get_env = |old, new| {
if let Ok(old_value) = std::env::var(old) {
println!(
"\x1b[33mWarning: The environment variable '{old}' is deprecated. Please use '{new}' instead.\x1b[0m",
);
Some(old_value)
} else {
None
}
};
options.private_key = options
.private_key
.or_else(|| get_env("TAURI_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY"));
options.private_key_path = options.private_key_path.or_else(|| {
get_env("TAURI_PRIVATE_KEY_PATH", "TAURI_SIGNING_PRIVATE_KEY_PATH").map(PathBuf::from)
});
options.password = options.password.or_else(|| {
get_env(
"TAURI_PRIVATE_KEY_PASSWORD",
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
)
});
options
}
pub fn command(mut options: Options) -> Result<()> {
options = backward_env_vars(options);
options.private_key = if let Some(private_key) = options.private_key_path {
Some(std::fs::read_to_string(Path::new(&private_key)).expect("Unable to extract private key"))
} else {
options.private_key
};
let private_key = if let Some(pk) = options.private_key {
pk
} else {
crate::error::bail!("Key generation aborted: Unable to find the private key");
};
if options.password.is_none() {
println!("Signing without password.");
}
if options.app_version.is_none() {
println!(
"Signing without an app version. Pass --app-version to bind this signature to a version; updaters configured with `requireSignedVersion` will reject this signature."
);
}
let (manifest_dir, signature) = sign_file(
&secret_key(private_key, options.password)?,
options.file,
options.app_version.as_deref(),
)
.with_context(|| "failed to sign file")?;
println!(
"\nYour file was signed successfully, You can find the signature here:\n{}\n\nPublic signature:\n{}\n\nMake sure to include this into the signature field of your update server.",
display_path(manifest_dir),
base64::engine::general_purpose::STANDARD.encode(signature.to_string())
);
Ok(())
}