Run Iris examples on desktop and Android

This commit is contained in:
iris committed 2026-09-11 11:33:04 -04:00
1 parent 37f956707e
commit b5666cdef9
25 files changed
+577 -205

No files matched your search

+168 -33
View File
@@ -1,4 +1,4 @@
use cargo_metadata::{CrateType, MetadataCommand, Package};
use cargo_metadata::{CrateType, MetadataCommand, Package, Target, TargetKind};
use std::{
env,
ffi::OsStr,
@@ -36,9 +36,9 @@ pub fn run(mut args: Vec<String>) -> Result<(), String> {
fn print_help() {
println!(
"Build an Iris library as an installable Android APK.\n\n\
"Build an Iris application or example as an installable Android APK.\n\n\
Usage:\n cargo iris apk [OPTIONS]\n cargo iris run --device SERIAL [OPTIONS]\n\n\
Options:\n --manifest-path PATH\n --package NAME\n --abi arm64-v8a|x86_64\n --release\n\
Options:\n --manifest-path PATH\n --package NAME\n --example NAME\n --abi arm64-v8a|x86_64\n --release\n\
\x20 --application-id ID\n --label TEXT\n --keystore PATH --key-alias ALIAS\n\n\
Release signing passwords come from IRIS_KEYSTORE_PASSWORD and, when different,\n\
IRIS_KEY_PASSWORD. Iris uses the Android SDK and emulator/device supplied by you."
@@ -49,6 +49,7 @@ fn print_help() {
struct Options {
manifest_path: Option<PathBuf>,
package: Option<String>,
example: Option<String>,
abi: String,
release: bool,
application_id: Option<String>,
@@ -77,6 +78,7 @@ impl Options {
options.manifest_path = Some(value("--manifest-path", &mut i)?.into())
}
"--package" => options.package = Some(value("--package", &mut i)?),
"--example" => options.example = Some(value("--example", &mut i)?),
"--abi" => options.abi = value("--abi", &mut i)?,
"--application-id" => {
options.application_id = Some(value("--application-id", &mut i)?)
@@ -128,43 +130,64 @@ fn build(options: &Options) -> Result<Built, String> {
metadata.root_package(),
options.package.as_deref(),
)?;
let target = package
.targets
.iter()
.find(|target| {
target
.crate_types
.iter()
.any(|kind| kind == &CrateType::CDyLib)
})
.ok_or_else(|| {
format!(
"package {} has no cdylib target; add `[lib] crate-type = [\"cdylib\", \"rlib\"]` to {}",
package.name, package.manifest_path
)
})?;
let target = select_target(package, options.example.as_deref())?;
let sdk = Sdk::find()?;
let application_id = options
.application_id
.clone()
.or_else(|| metadata_string(package, "application-id"))
.unwrap_or_else(|| default_application_id(&package.name));
.or_else(|| {
options
.example
.is_none()
.then(|| metadata_string(package, "application-id"))
.flatten()
})
.unwrap_or_else(|| default_application_id(&package.name, options.example.as_deref()));
validate_application_id(&application_id)?;
let label = options
.label
.clone()
.or_else(|| metadata_string(package, "label"))
.unwrap_or_else(|| package.name.to_string());
.or_else(|| {
options
.example
.is_none()
.then(|| metadata_string(package, "label"))
.flatten()
})
.unwrap_or_else(|| {
options
.example
.clone()
.unwrap_or_else(|| package.name.to_string())
});
let variant = if options.release { "release" } else { "debug" };
let artifact = options.example.as_ref().map_or_else(
|| package.name.to_string(),
|example| format!("{}-{example}", package.name),
);
let output = metadata
.target_directory
.as_std_path()
.join("iris-android")
.join(package.name.as_str())
.join(&artifact)
.join(variant)
.join(&options.abi);
recreate(&output)?;
let native = output.join("native");
let (build_manifest, library_name) = if options.example.is_some() {
let wrapper = metadata
.target_directory
.as_std_path()
.join("iris-android")
.join("example-wrappers")
.join(&artifact);
materialize_example_wrapper(&metadata.packages, package, target, &wrapper)?
} else {
(
package.manifest_path.as_std_path().to_path_buf(),
target.name.clone(),
)
};
let mut cargo = Command::new("cargo");
cargo
@@ -173,7 +196,8 @@ fn build(options: &Options) -> Result<Built, String> {
.arg("build")
.arg("--lib")
.arg("--manifest-path")
.arg(package.manifest_path.as_std_path());
.arg(&build_manifest)
.env("CARGO_TARGET_DIR", metadata.target_directory.as_std_path());
if options.release {
cargo.arg("--release");
}
@@ -185,13 +209,13 @@ fn build(options: &Options) -> Result<Built, String> {
let library = native
.join(&options.abi)
.join(format!("lib{}.so", target.name));
.join(format!("lib{library_name}.so"));
if !library.is_file() {
return Err(format!("cargo-ndk did not produce {}", library.display()));
}
let classes = output.join("classes");
fs::create_dir_all(&classes).map_err(io_error("create Java output", &classes))?;
let sources = materialize_host(&output, &target.name)?;
let sources = materialize_host(&output, &library_name)?;
let java_files = files_with_extension(&sources, "java")?;
let mut javac = Command::new("javac");
javac
@@ -229,7 +253,7 @@ fn build(options: &Options) -> Result<Built, String> {
let manifest = output.join("AndroidManifest.xml");
fs::write(
&manifest,
manifest_xml(&application_id, &label, &target.name, sdk.api),
manifest_xml(&application_id, &label, &library_name, sdk.api),
)
.map_err(io_error("write Android manifest", &manifest))?;
let unsigned = output.join("unsigned.apk");
@@ -258,7 +282,7 @@ fn build(options: &Options) -> Result<Built, String> {
&dex.join("classes.dex"),
&library,
&options.abi,
&target.name,
&library_name,
)?;
let aligned = output.join("aligned.apk");
@@ -272,7 +296,7 @@ fn build(options: &Options) -> Result<Built, String> {
"APK alignment",
"install Android SDK Build Tools",
)?;
let apk = output.join(format!("{}-{variant}.apk", package.name));
let apk = output.join(format!("{artifact}-{variant}.apk"));
sign(&sdk, options, &aligned, &apk)?;
verify(&sdk, &apk)?;
Ok(Built {
@@ -298,6 +322,106 @@ fn select_package<'a>(
})
}
fn select_target<'a>(package: &'a Package, example: Option<&str>) -> Result<&'a Target, String> {
if let Some(example) = example {
return package
.targets
.iter()
.find(|target| {
target.name == example
&& target.kind.iter().any(|kind| kind == &TargetKind::Example)
})
.ok_or_else(|| {
format!(
"package {} has no example named {example:?}; use one of: {}",
package.name,
package
.targets
.iter()
.filter(|target| target
.kind
.iter()
.any(|kind| kind == &TargetKind::Example))
.map(|target| target.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
});
}
package
.targets
.iter()
.find(|target| {
target
.crate_types
.iter()
.any(|kind| kind == &CrateType::CDyLib)
})
.ok_or_else(|| {
format!(
"package {} has no cdylib target; add `[lib] crate-type = [\"cdylib\", \"rlib\"]` to {}",
package.name, package.manifest_path
)
})
}
fn materialize_example_wrapper(
packages: &[Package],
package: &Package,
example: &Target,
wrapper: &Path,
) -> Result<(PathBuf, String), String> {
let android_source = example
.src_path
.as_std_path()
.parent()
.unwrap()
.join("android.rs");
if !android_source.is_file() {
return Err(format!(
"example {:?} has no Android entry point at {}; put shared code in lib.rs and add sibling desktop.rs and android.rs entries",
example.name,
android_source.display()
));
}
let iris = packages
.iter()
.find(|dependency| dependency.name == "iris")
.ok_or_else(|| {
format!(
"example {:?} does not depend on Iris; add `iris` to {}",
example.name, package.manifest_path
)
})?;
let source_dir = wrapper.join("src");
fs::create_dir_all(&source_dir)
.map_err(io_error("create Android example wrapper", &source_dir))?;
let library_name = format!(
"iris_android_{}_{}",
identifier_segment(&package.name),
identifier_segment(&example.name)
);
let iris_root = iris.manifest_path.parent().unwrap();
let manifest = format!(
"[package]\nname = {name:?}\nversion = \"0.0.0\"\nedition = \"2024\"\n\n\
[lib]\nname = {library_name:?}\ncrate-type = [\"cdylib\", \"rlib\"]\n\n\
[dependencies]\niris = {{ path = {iris_root:?} }}\n\n[workspace]\n",
name = format!("iris-android-{}-{}", package.name, example.name),
iris_root = iris_root.as_str(),
);
let manifest_path = wrapper.join("Cargo.toml");
fs::write(&manifest_path, manifest)
.map_err(io_error("write Android example manifest", &manifest_path))?;
let source = format!(
"#[path = {:?}]\nmod example;\n",
android_source.to_string_lossy()
);
let source_path = source_dir.join("lib.rs");
fs::write(&source_path, source)
.map_err(io_error("write Android example wrapper", &source_path))?;
Ok((manifest_path, library_name))
}
fn metadata_string(package: &Package, key: &str) -> Option<String> {
package
.metadata
@@ -308,8 +432,8 @@ fn metadata_string(package: &Package, key: &str) -> Option<String> {
.map(str::to_owned)
}
fn default_application_id(package: &str) -> String {
let segment: String = package
fn identifier_segment(value: &str) -> String {
value
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
@@ -318,8 +442,15 @@ fn default_application_id(package: &str) -> String {
'_'
}
})
.collect();
format!("dev.iris.app.{segment}")
.collect()
}
fn default_application_id(package: &str, example: Option<&str>) -> String {
let package = identifier_segment(package);
match example {
Some(example) => format!("dev.iris.example.{package}.{}", identifier_segment(example)),
None => format!("dev.iris.app.{package}"),
}
}
fn validate_application_id(id: &str) -> Result<(), String> {
@@ -699,6 +830,10 @@ mod tests {
assert!(validate_application_id("one").is_err());
assert!(validate_application_id("dev.2demo").is_err());
assert!(validate_application_id("dev.iris.bad-name").is_err());
assert_eq!(
default_application_id("demo-app", Some("color-picker")),
"dev.iris.example.demo_app.color_picker"
);
}
#[test]