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
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:
28
crates/tauri-cli/src/acl/capability/mod.rs
Normal file
28
crates/tauri-cli/src/acl/capability/mod.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
144
crates/tauri-cli/src/acl/capability/new.rs
Normal file
144
crates/tauri-cli/src/acl/capability/new.rs
Normal 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(())
|
||||
}
|
||||
42
crates/tauri-cli/src/acl/mod.rs
Normal file
42
crates/tauri-cli/src/acl/mod.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
282
crates/tauri-cli/src/acl/permission/add.rs
Normal file
282
crates/tauri-cli/src/acl/permission/add.rs
Normal 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(())
|
||||
}
|
||||
158
crates/tauri-cli/src/acl/permission/ls.rs
Normal file
158
crates/tauri-cli/src/acl/permission/ls.rs
Normal 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")
|
||||
}
|
||||
}
|
||||
39
crates/tauri-cli/src/acl/permission/mod.rs
Normal file
39
crates/tauri-cli/src/acl/permission/mod.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
124
crates/tauri-cli/src/acl/permission/new.rs
Normal file
124
crates/tauri-cli/src/acl/permission/new.rs
Normal 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(())
|
||||
}
|
||||
208
crates/tauri-cli/src/acl/permission/rm.rs
Normal file
208
crates/tauri-cli/src/acl/permission/rm.rs
Normal 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
328
crates/tauri-cli/src/add.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
279
crates/tauri-cli/src/build.rs
Normal file
279
crates/tauri-cli/src/build.rs
Normal 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(())
|
||||
}
|
||||
350
crates/tauri-cli/src/bundle.rs
Normal file
350
crates/tauri-cli/src/bundle.rs
Normal 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(())
|
||||
}
|
||||
103
crates/tauri-cli/src/completions.rs
Normal file
103
crates/tauri-cli/src/completions.rs
Normal 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
374
crates/tauri-cli/src/dev.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
30
crates/tauri-cli/src/dev/auto-reload.js
Normal file
30
crates/tauri-cli/src/dev/auto-reload.js
Normal 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
|
||||
})()
|
||||
169
crates/tauri-cli/src/dev/builtin_dev_server.rs
Normal file
169
crates/tauri-cli/src/dev/builtin_dev_server.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
105
crates/tauri-cli/src/error.rs
Normal file
105
crates/tauri-cli/src/error.rs
Normal 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;
|
||||
167
crates/tauri-cli/src/helpers/app_paths.rs
Normal file
167
crates/tauri-cli/src/helpers/app_paths.rs
Normal 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())
|
||||
}
|
||||
99
crates/tauri-cli/src/helpers/cargo.rs
Normal file
99
crates/tauri-cli/src/helpers/cargo.rs
Normal 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(())
|
||||
}
|
||||
209
crates/tauri-cli/src/helpers/cargo_manifest.rs
Normal file
209
crates/tauri-cli/src/helpers/cargo_manifest.rs
Normal 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
|
||||
}
|
||||
362
crates/tauri-cli/src/helpers/config.rs
Normal file
362
crates/tauri-cli/src/helpers/config.rs
Normal 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"
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
374
crates/tauri-cli/src/helpers/flock.rs
Normal file
374
crates/tauri-cli/src/helpers/flock.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
131
crates/tauri-cli/src/helpers/framework.rs
Normal file
131
crates/tauri-cli/src/helpers/framework.rs
Normal 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)
|
||||
}
|
||||
55
crates/tauri-cli/src/helpers/fs.rs
Normal file
55
crates/tauri-cli/src/helpers/fs.rs
Normal 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
|
||||
)
|
||||
}
|
||||
26
crates/tauri-cli/src/helpers/http.rs
Normal file
26
crates/tauri-cli/src/helpers/http.rs
Normal 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()
|
||||
}
|
||||
52
crates/tauri-cli/src/helpers/icns.json
Normal file
52
crates/tauri-cli/src/helpers/icns.json
Normal 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"
|
||||
}
|
||||
}
|
||||
152
crates/tauri-cli/src/helpers/mod.rs
Normal file
152
crates/tauri-cli/src/helpers/mod.rs
Normal 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(())
|
||||
}
|
||||
459
crates/tauri-cli/src/helpers/npm.rs
Normal file
459
crates/tauri-cli/src/helpers/npm.rs
Normal 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)
|
||||
}
|
||||
327
crates/tauri-cli/src/helpers/pbxproj.rs
Normal file
327
crates/tauri-cli/src/helpers/pbxproj.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
42
crates/tauri-cli/src/helpers/plist.rs
Normal file
42
crates/tauri-cli/src/helpers/plist.rs
Normal 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))
|
||||
}
|
||||
98
crates/tauri-cli/src/helpers/plugins.rs
Normal file
98
crates/tauri-cli/src/helpers/plugins.rs
Normal 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()
|
||||
)
|
||||
}
|
||||
}
|
||||
61
crates/tauri-cli/src/helpers/prompts.rs
Normal file
61
crates/tauri-cli/src/helpers/prompts.rs
Normal 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")
|
||||
}
|
||||
97
crates/tauri-cli/src/helpers/template.rs
Normal file
97
crates/tauri-cli/src/helpers/template.rs
Normal 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(())
|
||||
}
|
||||
297
crates/tauri-cli/src/helpers/updater_signature.rs
Normal file
297
crates/tauri-cli/src/helpers/updater_signature.rs
Normal 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
1160
crates/tauri-cli/src/icon.rs
Normal file
File diff suppressed because it is too large
Load Diff
51
crates/tauri-cli/src/info/app.rs
Normal file
51
crates/tauri-cli/src/info/app.rs
Normal 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
|
||||
}
|
||||
55
crates/tauri-cli/src/info/env_nodejs.rs
Normal file
55
crates/tauri-cli/src/info/env_nodejs.rs
Normal 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()),
|
||||
]
|
||||
}
|
||||
95
crates/tauri-cli/src/info/env_rust.rs
Normal file
95
crates/tauri-cli/src/info/env_rust.rs
Normal 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()
|
||||
}),
|
||||
]
|
||||
}
|
||||
359
crates/tauri-cli/src/info/env_system.rs
Normal file
359
crates/tauri-cli/src/info/env_system.rs
Normal 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()
|
||||
}),
|
||||
]
|
||||
}
|
||||
27
crates/tauri-cli/src/info/ios.rs
Normal file
27
crates/tauri-cli/src/info/ios.rs
Normal 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()
|
||||
}
|
||||
})]
|
||||
}
|
||||
349
crates/tauri-cli/src/info/mod.rs
Normal file
349
crates/tauri-cli/src/info/mod.rs
Normal 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(())
|
||||
}
|
||||
199
crates/tauri-cli/src/info/packages_nodejs.rs
Normal file
199
crates/tauri-cli/src/info/packages_nodejs.rs
Normal 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()
|
||||
})
|
||||
}
|
||||
100
crates/tauri-cli/src/info/packages_rust.rs
Normal file
100
crates/tauri-cli/src/info/packages_rust.rs
Normal 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())
|
||||
))
|
||||
}
|
||||
170
crates/tauri-cli/src/info/plugins.rs
Normal file
170
crates/tauri-cli/src/info/plugins.rs
Normal 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}"
|
||||
)))
|
||||
}
|
||||
465
crates/tauri-cli/src/init.rs
Normal file
465
crates/tauri-cli/src/init.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
57
crates/tauri-cli/src/inspect.rs
Normal file
57
crates/tauri-cli/src/inspect.rs
Normal 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(())
|
||||
}
|
||||
97
crates/tauri-cli/src/interface/mod.rs
Normal file
97
crates/tauri-cli/src/interface/mod.rs
Normal 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,
|
||||
}
|
||||
1968
crates/tauri-cli/src/interface/rust.rs
Normal file
1968
crates/tauri-cli/src/interface/rust.rs
Normal file
File diff suppressed because it is too large
Load Diff
149
crates/tauri-cli/src/interface/rust/cargo_config.rs
Normal file
149
crates/tauri-cli/src/interface/rust/cargo_config.rs
Normal 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(¤t.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)
|
||||
}
|
||||
}
|
||||
462
crates/tauri-cli/src/interface/rust/desktop.rs
Normal file
462
crates/tauri-cli/src/interface/rust/desktop.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
39
crates/tauri-cli/src/interface/rust/installation.rs
Normal file
39
crates/tauri-cli/src/interface/rust/installation.rs
Normal 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)
|
||||
}
|
||||
507
crates/tauri-cli/src/interface/rust/manifest.rs
Normal file
507
crates/tauri-cli/src/interface/rust/manifest.rs
Normal 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
444
crates/tauri-cli/src/lib.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
43
crates/tauri-cli/src/main.rs
Normal file
43
crates/tauri-cli/src/main.rs
Normal 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)
|
||||
}
|
||||
6
crates/tauri-cli/src/migrate/migrations/mod.rs
Normal file
6
crates/tauri-cli/src/migrate/migrations/mod.rs
Normal 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;
|
||||
1260
crates/tauri-cli/src/migrate/migrations/v1/config.rs
Normal file
1260
crates/tauri-cli/src/migrate/migrations/v1/config.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
736
crates/tauri-cli/src/migrate/migrations/v1/frontend.rs
Normal file
736
crates/tauri-cli/src/migrate/migrations/v1/frontend.rs
Normal 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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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");"#);
|
||||
}
|
||||
}
|
||||
@@ -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(/'/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(/'/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()
|
||||
);
|
||||
}
|
||||
}
|
||||
415
crates/tauri-cli/src/migrate/migrations/v1/manifest.rs
Normal file
415
crates/tauri-cli/src/migrate/migrations/v1/manifest.rs
Normal 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
crates/tauri-cli/src/migrate/migrations/v1/mod.rs
Normal file
34
crates/tauri-cli/src/migrate/migrations/v1/mod.rs
Normal 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(())
|
||||
}
|
||||
194
crates/tauri-cli/src/migrate/migrations/v2_beta.rs
Normal file
194
crates/tauri-cli/src/migrate/migrations/v2_beta.rs
Normal 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());
|
||||
}
|
||||
72
crates/tauri-cli/src/migrate/mod.rs
Normal file
72
crates/tauri-cli/src/migrate/mod.rs
Normal 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(())
|
||||
}
|
||||
334
crates/tauri-cli/src/mobile/android/android_studio_script.rs
Normal file
334
crates/tauri-cli/src/mobile/android/android_studio_script.rs
Normal 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()
|
||||
}
|
||||
342
crates/tauri-cli/src/mobile/android/build.rs
Normal file
342
crates/tauri-cli/src/mobile/android/build.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
413
crates/tauri-cli/src/mobile/android/dev.rs
Normal file
413
crates/tauri-cli/src/mobile/android/dev.rs
Normal 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")
|
||||
}
|
||||
1233
crates/tauri-cli/src/mobile/android/mod.rs
Normal file
1233
crates/tauri-cli/src/mobile/android/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
209
crates/tauri-cli/src/mobile/android/project.rs
Normal file
209
crates/tauri-cli/src/mobile/android/project.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
184
crates/tauri-cli/src/mobile/android/run.rs
Normal file
184
crates/tauri-cli/src/mobile/android/run.rs
Normal 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(())
|
||||
}
|
||||
414
crates/tauri-cli/src/mobile/init.rs
Normal file
414
crates/tauri-cli/src/mobile/init.rs
Normal 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
|
||||
})
|
||||
}
|
||||
665
crates/tauri-cli/src/mobile/ios/build.rs
Normal file
665
crates/tauri-cli/src/mobile/ios/build.rs
Normal 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"
|
||||
),
|
||||
}
|
||||
}
|
||||
407
crates/tauri-cli/src/mobile/ios/dev.rs
Normal file
407
crates/tauri-cli/src/mobile/ios/dev.rs
Normal 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)
|
||||
}
|
||||
704
crates/tauri-cli/src/mobile/ios/mod.rs
Normal file
704
crates/tauri-cli/src/mobile/ios/mod.rs
Normal 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(())
|
||||
}
|
||||
239
crates/tauri-cli/src/mobile/ios/project.rs
Normal file
239
crates/tauri-cli/src/mobile/ios/project.rs
Normal 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(())
|
||||
}
|
||||
144
crates/tauri-cli/src/mobile/ios/run.rs
Normal file
144
crates/tauri-cli/src/mobile/ios/run.rs
Normal 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(())
|
||||
}
|
||||
354
crates/tauri-cli/src/mobile/ios/xcode_script.rs
Normal file
354
crates/tauri-cli/src/mobile/ios/xcode_script.rs
Normal 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()
|
||||
)
|
||||
}
|
||||
642
crates/tauri-cli/src/mobile/mod.rs
Normal file
642
crates/tauri-cli/src/mobile/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
141
crates/tauri-cli/src/plugin/android.rs
Normal file
141
crates/tauri-cli/src/plugin/android.rs
Normal 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(())
|
||||
}
|
||||
329
crates/tauri-cli/src/plugin/init.rs
Normal file
329
crates/tauri-cli/src/plugin/init.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
155
crates/tauri-cli/src/plugin/ios.rs
Normal file
155
crates/tauri-cli/src/plugin/ios.rs
Normal 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(())
|
||||
}
|
||||
96
crates/tauri-cli/src/plugin/mod.rs
Normal file
96
crates/tauri-cli/src/plugin/mod.rs
Normal 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(),
|
||||
)
|
||||
}
|
||||
88
crates/tauri-cli/src/plugin/new.rs
Normal file
88
crates/tauri-cli/src/plugin/new.rs
Normal 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())
|
||||
}
|
||||
59
crates/tauri-cli/src/remove.rs
Normal file
59
crates/tauri-cli/src/remove.rs
Normal 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(())
|
||||
}
|
||||
77
crates/tauri-cli/src/signer/generate.rs
Normal file
77
crates/tauri-cli/src/signer/generate.rs
Normal 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(())
|
||||
}
|
||||
36
crates/tauri-cli/src/signer/mod.rs
Normal file
36
crates/tauri-cli/src/signer/mod.rs
Normal 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(())
|
||||
}
|
||||
121
crates/tauri-cli/src/signer/sign.rs
Normal file
121
crates/tauri-cli/src/signer/sign.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user