use std::collections::HashSet;
use std::path::Path;
use std::process::Command;
use serde::{Deserialize, Serialize};
use crate::meta::Project;
#[derive(Debug, Default, Deserialize)]
struct PermissionsMeta {
#[serde(default)]
uses: Vec<String>,
}
#[derive(Debug, Default, Serialize)]
pub struct AndroidPieces {
#[serde(rename = "dayJavaSrcDir")]
pub day_java_src_dir: Option<String>,
#[serde(rename = "dayProguardFile")]
pub day_proguard_file: Option<String>,
#[serde(rename = "javaSrcDirs")]
pub java_src_dirs: Vec<String>,
#[serde(rename = "resSrcDirs")]
pub res_src_dirs: Vec<String>,
pub dependencies: Vec<String>,
pub repositories: Vec<String>,
pub permissions: Vec<String>,
#[serde(rename = "proguardFiles")]
pub proguard_files: Vec<String>,
#[serde(rename = "manifestComponents")]
pub manifest_components: Vec<String>,
}
#[derive(Deserialize)]
pub(crate) struct Metadata {
pub(crate) packages: Vec<Package>,
resolve: Option<Resolve>,
}
#[derive(Deserialize)]
pub(crate) struct Package {
id: String,
pub(crate) name: String,
pub(crate) manifest_path: String,
#[serde(default)]
pub(crate) metadata: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct Resolve {
root: Option<String>,
nodes: Vec<Node>,
}
#[derive(Deserialize)]
struct Node {
id: String,
#[serde(default)]
deps: Vec<Dep>,
}
#[derive(Deserialize)]
struct Dep {
pkg: String,
}
#[derive(Deserialize, Default)]
struct AndroidMeta {
#[serde(default)]
java: StringOrVec,
#[serde(default)]
res: StringOrVec,
#[serde(default, rename = "gradle-dependencies")]
gradle_dependencies: Vec<String>,
#[serde(default, rename = "gradle-repositories")]
gradle_repositories: Vec<String>,
#[serde(default)]
permissions: Vec<String>,
#[serde(default)]
proguard: StringOrVec,
#[serde(default, rename = "manifest-components")]
manifest_components: StringOrVec,
}
#[derive(Default)]
struct StringOrVec(Vec<String>);
impl<'de> Deserialize<'de> for StringOrVec {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum V {
One(String),
Many(Vec<String>),
}
Ok(StringOrVec(match V::deserialize(d)? {
V::One(s) => vec![s],
V::Many(v) => v,
}))
}
}
#[derive(Deserialize, Default)]
struct PieceMeta {
#[serde(default)]
backends: Vec<String>,
}
pub fn feature_union(project: &Project, backend: &str) -> Vec<String> {
let meta = match cargo_metadata(project, &[backend]) {
Ok(m) => m,
Err(e) => {
eprintln!(
"day: piece feature discovery failed ({e}); \
building with the app's declared features only"
);
return Vec::new();
}
};
let in_closure = closure(&meta);
let mut feats = Vec::new();
for pkg in &meta.packages {
if !in_closure.contains(&pkg.id) {
continue;
}
let Some(piece) = piece_meta::<PieceMeta>(pkg, "piece") else {
continue;
};
if piece.backends.iter().any(|b| b == backend) {
feats.push(format!("{}/{backend}", pkg.name));
}
}
feats.sort();
feats.dedup();
feats
}
pub(crate) fn cargo_metadata(project: &Project, features: &[&str]) -> Result<Metadata, String> {
cargo_metadata_inner(project, features, false)
}
pub(crate) fn cargo_metadata_all_features(project: &Project) -> Result<Metadata, String> {
cargo_metadata_inner(project, &[], true)
}
fn cargo_metadata_inner(
project: &Project,
features: &[&str],
all_features: bool,
) -> Result<Metadata, String> {
let manifest = project.root.join("Cargo.toml");
let mut cmd = Command::new("cargo");
cmd.current_dir(&project.root)
.args(["metadata", "--format-version", "1"])
.arg("--manifest-path")
.arg(&manifest);
if all_features {
cmd.arg("--all-features");
} else {
cmd.arg("--no-default-features");
}
if !features.is_empty() {
cmd.arg("--features").arg(features.join(","));
}
let out = cmd.output().map_err(|e| format!("cargo metadata: {e}"))?;
if !out.status.success() {
return Err(format!(
"cargo metadata failed: {}",
String::from_utf8_lossy(&out.stderr)
.lines()
.next_back()
.unwrap_or("")
));
}
serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata parse: {e}"))
}
pub(crate) fn piece_meta<T: serde::de::DeserializeOwned>(
pkg: &Package,
toolkit: &str,
) -> Option<T> {
let table = pkg
.metadata
.as_ref()
.and_then(|m| m.get("day")) .and_then(|d| d.get(toolkit))?;
match serde_json::from_value(table.clone()) {
Ok(v) => Some(v),
Err(e) => {
eprintln!(
"day: {} has malformed [package.metadata.day.{toolkit}]: {e}",
pkg.manifest_path
);
None
}
}
}
pub fn resolve_android(project: &Project, features: &[&str]) -> Result<AndroidPieces, String> {
let meta = cargo_metadata(project, features)?;
let in_closure = closure(&meta);
let mut pieces = AndroidPieces::default();
let mut seen_java = HashSet::new();
for pkg in &meta.packages {
if !in_closure.contains(&pkg.id) {
continue;
}
if pkg.name == "day-android" {
let java = Path::new(&pkg.manifest_path)
.parent()
.unwrap_or(Path::new("."))
.join("java");
if !java.is_dir() {
return Err(format!(
"day-android crate at {:?} has no java/ dir — the Android Java shim is \
missing from this day checkout",
pkg.manifest_path
));
}
pieces.day_java_src_dir = Some(java.to_string_lossy().into_owned());
let rules = Path::new(&pkg.manifest_path)
.parent()
.unwrap_or(Path::new("."))
.join("proguard-rules.pro");
if rules.is_file() {
pieces.day_proguard_file = Some(rules.to_string_lossy().into_owned());
}
}
let Some(android) = piece_meta::<AndroidMeta>(pkg, "android") else {
continue;
};
let crate_dir = Path::new(&pkg.manifest_path)
.parent()
.unwrap_or(Path::new("."));
for rel in &android.java.0 {
let dir = crate_dir.join(rel);
if !dir.is_dir() {
eprintln!("day: {} java dir {:?} not found — skipping", pkg.id, dir);
continue;
}
let abs = dir.to_string_lossy().into_owned();
if seen_java.insert(abs.clone()) {
pieces.java_src_dirs.push(abs);
}
}
for rel in &android.res.0 {
let dir = crate_dir.join(rel);
if !dir.is_dir() {
eprintln!("day: {} res dir {:?} not found — skipping", pkg.id, dir);
continue;
}
let abs = dir.to_string_lossy().into_owned();
if !pieces.res_src_dirs.contains(&abs) {
pieces.res_src_dirs.push(abs);
}
}
for dep in android.gradle_dependencies {
if !pieces.dependencies.contains(&dep) {
pieces.dependencies.push(dep);
}
}
for repo in android.gradle_repositories {
if !pieces.repositories.contains(&repo) {
pieces.repositories.push(repo);
}
}
for perm in android.permissions {
if !pieces.permissions.contains(&perm) {
pieces.permissions.push(perm);
}
}
for rel in &android.proguard.0 {
let file = crate_dir.join(rel);
if !file.is_file() {
eprintln!(
"day: {} proguard file {:?} not found — skipping",
pkg.id, file
);
continue;
}
let abs = file.to_string_lossy().into_owned();
if !pieces.proguard_files.contains(&abs) {
pieces.proguard_files.push(abs);
}
}
for rel in &android.manifest_components.0 {
let file = crate_dir.join(rel);
if !file.is_file() {
return Err(format!(
"{}: manifest-components file {:?} not found",
pkg.id, file
));
}
let abs = file.to_string_lossy().into_owned();
if !pieces.manifest_components.contains(&abs) {
pieces.manifest_components.push(abs);
}
}
}
Ok(pieces)
}
fn closure(meta: &Metadata) -> HashSet<String> {
let Some(resolve) = &meta.resolve else {
return meta.packages.iter().map(|p| p.id.clone()).collect();
};
let by_id: std::collections::HashMap<&str, &Node> =
resolve.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
let Some(root) = &resolve.root else {
return resolve.nodes.iter().map(|n| n.id.clone()).collect();
};
let mut seen = HashSet::new();
let mut stack = vec![root.clone()];
while let Some(id) = stack.pop() {
if !seen.insert(id.clone()) {
continue;
}
if let Some(node) = by_id.get(id.as_str()) {
for d in &node.deps {
stack.push(d.pkg.clone());
}
}
}
seen
}
pub fn contributed_permissions(project: &Project, backends: &[&str]) -> Vec<(String, String)> {
let Ok(meta) = cargo_metadata(project, backends) else {
return Vec::new();
};
let reachable = closure(&meta);
let mut out = Vec::new();
for pkg in meta.packages.iter().filter(|p| reachable.contains(&p.id)) {
if let Some(m) = piece_meta::<PermissionsMeta>(pkg, "permissions") {
for perm in m.uses {
out.push((pkg.name.clone(), perm));
}
}
}
out.sort();
out.dedup();
out
}
pub fn write_android_manifest(project: &Project) -> Result<(), String> {
let mut pieces = resolve_android(project, &["mdc"]).unwrap_or_else(|e| {
eprintln!("day: piece discovery failed ({e}); building with framework pieces only");
AndroidPieces::default()
});
let dir = project.root.join("build/day/android");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let contributed = contributed_permissions(project, &["mdc"]);
let declared = crate::permissions::resolve(&project.manifest, "android", &contributed)
.map_err(|e| format!("Day.toml: {e}"))?;
let mut entries = crate::permissions::android_entries(&declared);
for name in &pieces.permissions {
if !entries.iter().any(|e| &e.name == name) {
entries.push(crate::permissions::AndroidRaw {
name: name.clone(),
max_sdk: None,
});
}
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
pieces.permissions = entries.iter().map(|e| e.name.clone()).collect();
let kotlin_arms = crate::bridge::kotlin_arm_crates(project);
if !kotlin_arms.is_empty() && !crate::bridge::android_compiles_kotlin(project) {
return Err(crate::bridge::kotlin_plugin_help(&kotlin_arms));
}
match crate::bridge::write_jvm(project, &crate::bridge::stage(project, "android")) {
Ok(Some(dir)) => pieces.java_src_dirs.push(dir.display().to_string()),
Ok(None) => {}
Err(e) => eprintln!("day: bridge staging failed ({e}); building without bridged Kotlin"),
}
let json = serde_json::to_string_pretty(&pieces).map_err(|e| e.to_string())?;
std::fs::write(dir.join("day-pieces.json"), json).map_err(|e| e.to_string())?;
let overlay = dir.join("day-pieces-manifest.xml");
let mut components = read_manifest_components(&pieces.manifest_components)?;
if let Some(frag) = crate::shortcuts::android_manifest_fragment(project) {
components.push(frag);
}
if entries.is_empty() && components.is_empty() {
let _ = std::fs::remove_file(&overlay);
} else {
if entries.is_empty() {
eprintln!(
"day: a dependency or Day.toml [[shortcuts]] contributes Android manifest \
components but no permissions. \
If this app's platform/android/app/build.gradle.kts still reads \
`if (piecePermissions.isNotEmpty() && pieceManifest.exists())`, change it to \
`if (pieceManifest.exists())` or the components will not be merged."
);
}
std::fs::write(&overlay, pieces_manifest(&entries, &components))
.map_err(|e| e.to_string())?;
}
Ok(())
}
fn strip_xml_comments(xml: &str) -> String {
let mut out = String::with_capacity(xml.len());
let mut rest = xml;
while let Some(start) = rest.find("<!--") {
out.push_str(&rest[..start]);
match rest[start..].find("-->") {
Some(end) => rest = &rest[start + end + 3..],
None => return out,
}
}
out.push_str(rest);
out
}
fn read_manifest_components(paths: &[String]) -> Result<Vec<String>, String> {
let mut out = Vec::new();
for path in paths {
let body = std::fs::read_to_string(path)
.map_err(|e| format!("manifest-components {path}: {e}"))?;
let trimmed = body.trim();
if trimmed.is_empty() {
return Err(format!("manifest-components {path}: file is empty"));
}
let code = strip_xml_comments(trimmed);
if code.contains("<manifest") || code.contains("<application") {
return Err(format!(
"manifest-components {path}: contains a <manifest>/<application> wrapper — the \
file must hold ONLY the elements that go inside <application> (the CLI adds the \
wrapper)"
));
}
out.push(trimmed.to_string());
}
Ok(out)
}
fn pieces_manifest(
permissions: &[crate::permissions::AndroidRaw],
components: &[String],
) -> String {
let mut s = String::from(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<!-- Generated by `day build` from Day.toml [permissions] and \
[package.metadata.day.*] contributions. Do not edit. -->\n\
<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n",
);
for perm in permissions {
match perm.max_sdk {
Some(max) => s.push_str(&format!(
" <uses-permission android:name=\"{}\" android:maxSdkVersion=\"{max}\" />\n",
perm.name
)),
None => s.push_str(&format!(
" <uses-permission android:name=\"{}\" />\n",
perm.name
)),
}
}
if !components.is_empty() {
s.push_str(" <application>\n");
for frag in components {
for line in frag.lines() {
if line.trim().is_empty() {
s.push('\n');
} else {
s.push_str(" ");
s.push_str(line.trim_end());
s.push('\n');
}
}
}
s.push_str(" </application>\n");
}
s.push_str("</manifest>\n");
s
}
#[derive(Debug, Clone, Deserialize)]
struct SwiftPackage {
#[serde(default)]
url: Option<String>,
#[serde(default)]
path: Option<String>,
#[serde(default)]
from: Option<String>,
#[serde(default)]
exact: Option<String>,
#[serde(default)]
branch: Option<String>,
#[serde(default)]
revision: Option<String>,
#[serde(default)]
products: Vec<String>,
}
impl SwiftPackage {
fn identity(&self) -> String {
let base = self.path.as_deref().or(self.url.as_deref()).unwrap_or("");
base.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or(base)
.trim_end_matches(".git")
.to_string()
}
fn product_names(&self) -> Vec<String> {
if self.products.is_empty() {
vec![self.identity()]
} else {
self.products.clone()
}
}
fn dependency_clause(&self) -> String {
match &self.path {
Some(p) => format!(".package(path: \"{p}\")"),
None => format!(
".package(url: \"{}\", {})",
self.url.as_deref().unwrap_or(""),
self.requirement()
),
}
}
fn requirement(&self) -> String {
if let Some(v) = &self.exact {
format!("exact: \"{v}\"")
} else if let Some(b) = &self.branch {
format!("branch: \"{b}\"")
} else if let Some(r) = &self.revision {
format!("revision: \"{r}\"")
} else {
format!("from: \"{}\"", self.from.as_deref().unwrap_or("0.0.0"))
}
}
}
#[derive(Deserialize, Default)]
struct AppleMeta {
#[serde(default)]
swift: StringOrVec,
#[serde(default, rename = "swift-packages")]
swift_packages: Vec<SwiftPackage>,
#[serde(default)]
frameworks: Vec<String>,
#[serde(default)]
platform: Option<String>,
}
#[derive(Default)]
struct ApplePieces {
swift_dirs: Vec<(String, String)>,
packages: Vec<SwiftPackage>,
frameworks: Vec<String>,
platform: Option<String>,
scan_roots: Vec<(String, String)>,
has_swiftui_piece: bool,
}
fn max_platform(a: &str, b: &str) -> String {
fn key(v: &str) -> Vec<u32> {
v.split('.')
.map(|c| c.trim().parse().unwrap_or(0))
.collect()
}
if key(b) > key(a) {
b.to_string()
} else {
a.to_string()
}
}
fn resolve_apple(
project: &Project,
features: &[&str],
platform_key: &str,
) -> Result<ApplePieces, String> {
let meta = cargo_metadata(project, features)?;
let in_closure = closure(&meta);
let mut pieces = ApplePieces::default();
let mut seen_dirs = HashSet::new();
let mut seen_pkgs = HashSet::new();
for pkg in &meta.packages {
if !in_closure.contains(&pkg.id) {
continue;
}
if pkg.name == "day-piece-swiftui" {
pieces.has_swiftui_piece = true;
}
let Some(apple) = piece_meta::<AppleMeta>(pkg, platform_key) else {
continue;
};
let crate_dir = Path::new(&pkg.manifest_path)
.parent()
.unwrap_or(Path::new("."));
let namespace = crate_dir
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "piece".into());
for rel in &apple.swift.0 {
let dir = crate_dir.join(rel);
if !dir.is_dir() {
eprintln!("day: {} swift dir {:?} not found — skipping", pkg.id, dir);
continue;
}
let abs = dir.to_string_lossy().into_owned();
if seen_dirs.insert(abs.clone()) {
pieces.swift_dirs.push((namespace.clone(), abs));
}
}
for mut spkg in apple.swift_packages {
if let Some(rel) = &spkg.path {
let dir = crate_dir.join(rel);
if !dir.join("Package.swift").is_file() {
eprintln!(
"day: {} swift package {:?} not found (no Package.swift) — skipping",
pkg.id, dir
);
continue;
}
let abs = dir.to_string_lossy().into_owned();
spkg.path = Some(abs.clone());
if seen_pkgs.insert(format!("path:{abs}")) {
pieces.scan_roots.push((spkg.identity(), abs));
pieces.packages.push(spkg);
}
} else if spkg.url.is_some() {
if seen_pkgs.insert(spkg.identity()) {
pieces.packages.push(spkg);
}
} else {
eprintln!(
"day: {} has a swift-packages entry with neither `url` nor `path` — skipping",
pkg.id
);
}
}
for fw in apple.frameworks {
if !pieces.frameworks.contains(&fw) {
pieces.frameworks.push(fw);
}
}
if let Some(p) = apple.platform {
pieces.platform = Some(match pieces.platform.take() {
Some(cur) => max_platform(&cur, &p),
None => p,
});
}
}
Ok(pieces)
}
pub fn write_ios_pieces(project: &Project) -> Result<Option<String>, String> {
let pieces = resolve_apple(project, &["uikit"], "ios").unwrap_or_else(|e| {
eprintln!("day: iOS piece discovery failed ({e}); building with framework pieces only");
ApplePieces::default()
});
let pkg_dir = project.root.join("build/day/ios/DayPieces");
let sources = pkg_dir.join("Sources/DayPieces");
std::fs::create_dir_all(&sources).map_err(|e| e.to_string())?;
let mut expected: Vec<std::path::PathBuf> = Vec::new();
let placeholder = sources.join("_DayPieces.swift");
write_if_changed(
&placeholder,
"// Generated by `day build`. The DayPieces local package aggregates every standalone piece's\n\
// iOS Swift shims and SwiftPM package dependencies (docs/extending.md). Do not edit.\n\
enum _DayPieces {}\n",
)?;
expected.push(placeholder);
for (namespace, dir) in &pieces.swift_dirs {
sync_swift_dir(Path::new(dir), &sources.join(namespace), &mut expected)?;
}
stage_bridge_swift(project, "ios", &sources, &mut expected)?;
if let Some(glue) = render_view_glue(&pieces) {
let path = sources.join("_DayViews.swift");
write_if_changed(&path, &glue)?;
expected.push(path);
}
let images = crate::resources::ResourceSet::scan(project, "uikit").images;
let vectors: Vec<(String, std::path::PathBuf)> =
std::fs::read_dir(crate::resources::vector_svg_dir(project))
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("svg"))
.filter_map(|p| {
let stem = p.file_stem()?.to_str()?.to_string();
Some((stem, p))
})
.collect();
let has_resources = crate::resources::apple::write_media_xcassets(&sources, &images, &vectors)?;
let fonts = crate::resources::scan_fonts(project)?;
if !fonts.is_empty() {
let fdir = sources.join("fonts");
std::fs::create_dir_all(&fdir).map_err(|e| e.to_string())?;
for f in &fonts {
let name = f.path.file_name().ok_or("font file name")?;
copy_if_changed(&f.path, &fdir.join(name))?;
}
}
collect_files(&sources.join("Media.xcassets"), &mut expected);
collect_files(&sources.join("fonts"), &mut expected);
prune_except(&sources, &expected.into_iter().collect());
let floor = max_platform("15.0", pieces.platform.as_deref().unwrap_or("15.0"));
write_if_changed(
&pkg_dir.join("Package.swift"),
&package_swift(
&pieces,
"ios",
&format!(".iOS(\"{floor}\")"),
false,
has_resources,
!fonts.is_empty(),
),
)?;
let pbx = pbxproj_ios_target(project).unwrap_or_else(|| "15.0".into());
Ok((max_platform(&pbx, &floor) != pbx).then_some(floor))
}
fn pbxproj_ios_target(project: &Project) -> Option<String> {
[
"platform/ios/DayApp.xcodeproj/project.pbxproj",
"platform/ios/DayApp.xcconfig",
]
.iter()
.filter_map(|rel| std::fs::read_to_string(project.root.join(rel)).ok())
.filter_map(|text| ios_target_from_pbxproj(&text))
.reduce(|a, b| max_platform(&a, &b))
}
fn ios_target_from_pbxproj(text: &str) -> Option<String> {
text.lines()
.filter_map(|l| {
let (key, value) = l.trim().split_once('=')?;
(key.trim() == "IPHONEOS_DEPLOYMENT_TARGET")
.then(|| value.trim().trim_end_matches(';').trim().to_string())
})
.reduce(|a, b| max_platform(&a, &b))
}
fn render_view_glue(pieces: &ApplePieces) -> Option<String> {
if pieces.scan_roots.is_empty() {
return None;
}
if !pieces.has_swiftui_piece {
eprintln!(
"day: local Swift packages are declared but day-piece-swiftui is not a dependency — \
skipping the SwiftUI view export (docs/swiftui.md)"
);
return None;
}
let mut scans = Vec::new();
for (name, dir) in &pieces.scan_roots {
match day_build::swiftui::scan_package(Path::new(dir)) {
Ok(scan) => {
for (view, reason) in &scan.skipped {
eprintln!("day: swiftui: {view} not exported — {reason}");
}
scans.push((name.clone(), scan));
}
Err(e) => eprintln!("day: swiftui view scan failed for {name}: {e}"),
}
}
scans
.iter()
.any(|(_, s)| !s.views.is_empty())
.then(|| day_build::swiftui::render_glue(&scans))
}
#[derive(Deserialize, Default)]
struct OhosMeta {
#[serde(default)]
ets: StringOrVec,
}
#[derive(Default)]
struct OhosPieces {
ets_dirs: Vec<(String, String)>,
}
fn resolve_ohos(project: &Project, features: &[&str]) -> Result<OhosPieces, String> {
let meta = cargo_metadata(project, features)?;
let in_closure = closure(&meta);
let mut pieces = OhosPieces::default();
let mut seen = HashSet::new();
for pkg in &meta.packages {
if !in_closure.contains(&pkg.id) {
continue;
}
let Some(ohos) = piece_meta::<OhosMeta>(pkg, "ohos") else {
continue;
};
let crate_dir = Path::new(&pkg.manifest_path)
.parent()
.unwrap_or(Path::new("."));
let namespace = crate_dir
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "piece".into());
for rel in &ohos.ets.0 {
let dir = crate_dir.join(rel);
if !dir.is_dir() {
eprintln!("day: {} ets dir {:?} not found — skipping", pkg.id, dir);
continue;
}
if !dir.join("Index.ets").is_file() {
eprintln!(
"day: {} ets dir {:?} has no Index.ets (the `dayPiece` entry module) — skipping",
pkg.id, dir
);
continue;
}
let abs = dir.to_string_lossy().into_owned();
if seen.insert(abs.clone()) {
pieces.ets_dirs.push((namespace.clone(), abs));
}
}
}
pieces.ets_dirs.sort();
Ok(pieces)
}
pub fn write_ohos_pieces(project: &Project, harmony: &Path) -> Result<(), String> {
let pieces = resolve_ohos(project, &["arkui"]).unwrap_or_else(|e| {
eprintln!(
"day: HarmonyOS piece discovery failed ({e}); building with framework pieces only"
);
OhosPieces::default()
});
let dir = harmony.join("entry/src/main/ets/daypieces");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let mut expected: Vec<std::path::PathBuf> = Vec::new();
let contract = dir.join("DayPiece.ets");
write_if_changed(&contract, DAY_PIECE_ETS)?;
expected.push(contract);
for (namespace, src) in &pieces.ets_dirs {
sync_ets_dir(Path::new(src), &dir.join(namespace), &mut expected)?;
}
let aggregator = dir.join("DayPieces.ets");
write_if_changed(&aggregator, &day_pieces_ets(&pieces))?;
expected.push(aggregator);
prune_except(&dir, &expected.into_iter().collect());
crate::bridge::write_arkts(harmony, &crate::bridge::stage(project, "ohos"))?;
Ok(())
}
const DAY_PIECE_ETS: &str = r#"// Generated by `day build`. The contract between a standalone piece's ArkTS and the generated
// aggregator (docs/extending.md). Do not edit.
import { FrameNode, UIContext } from '@kit.ArkUI';
export interface DayPieceModule {
// The piece kind this module renders, matching the Rust `KIND` (e.g. 'day.piece.webview').
kind: string;
// Build the component and return its FrameNode; undefined declines the node (Day then renders
// its placeholder leaf). `props` is whatever the piece's Rust renderer encoded.
make: (ui: UIContext, id: number, props: string) => FrameNode | undefined;
// A command from the piece's Rust renderer. `cmd`/`arg` are the piece's own vocabulary.
update: (id: number, cmd: string, arg: string) => void;
// Release everything held for `id` — Day disposed the node.
dispose: (id: number) => void;
}
"#;
fn day_pieces_ets(pieces: &OhosPieces) -> String {
let mut imports = String::new();
let mut entries = String::new();
for (i, (namespace, _)) in pieces.ets_dirs.iter().enumerate() {
imports.push_str(&format!(
"import {{ dayPiece as dayPiece{i} }} from './{namespace}/Index';\n"
));
entries.push_str(&format!(" dayPiece{i},\n"));
}
format!(
r#"// Generated by `day build`. Registers every standalone piece's ArkTS component with the native
// shim (docs/extending.md): one factory, one command sink, one disposer for all of them. Do not edit.
import nativeEntry from 'libentry.so';
import {{ FrameNode, UIContext }} from '@kit.ArkUI';
import {{ DayPieceModule }} from './DayPiece';
{imports}
const dayPieces: DayPieceModule[] = [
{entries}];
// Which module owns a live node, so commands and disposal reach the right piece.
const dayPieceOwners: Map<number, DayPieceModule> = new Map();
// Call once, before `start()`: a piece node can be realized during the first tree build.
export function registerDayPieces(ui: UIContext): void {{
nativeEntry.registerPiece(
(kind: string, id: number, props: string): FrameNode | undefined => {{
for (const m of dayPieces) {{
if (m.kind === kind) {{
const node: FrameNode | undefined = m.make(ui, id, props);
if (node !== undefined) {{
dayPieceOwners.set(id, m);
}}
return node;
}}
}}
return undefined;
}},
(id: number, cmd: string, arg: string): void => {{
dayPieceOwners.get(id)?.update(id, cmd, arg);
}},
(id: number): void => {{
const m: DayPieceModule | undefined = dayPieceOwners.get(id);
if (m !== undefined) {{
dayPieceOwners.delete(id);
m.dispose(id);
}}
}}
);
}}
"#
)
}
fn sync_ets_dir(
src: &Path,
dest: &Path,
expected: &mut Vec<std::path::PathBuf>,
) -> Result<(), String> {
std::fs::create_dir_all(dest).map_err(|e| e.to_string())?;
let rd = std::fs::read_dir(src).map_err(|e| format!("{}: {e}", src.display()))?;
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
sync_ets_dir(&path, &dest.join(entry.file_name()), expected)?;
} else if path.extension().and_then(|e| e.to_str()) == Some("ets") {
let target = dest.join(entry.file_name());
copy_if_changed(&path, &target)?;
expected.push(target);
}
}
Ok(())
}
fn package_swift(
pieces: &ApplePieces,
meta_key: &str,
platform_clause: &str,
static_product: bool,
has_resources: bool,
has_fonts: bool,
) -> String {
let deps: String = pieces
.packages
.iter()
.map(|p| format!(" {},\n", p.dependency_clause()))
.collect();
let products: String = pieces
.packages
.iter()
.flat_map(|p| {
let id = p.identity();
p.product_names().into_iter().map(move |prod| {
format!(" .product(name: \"{prod}\", package: \"{id}\"),\n")
})
})
.collect();
let linker: String = if pieces.frameworks.is_empty() {
String::new()
} else {
let fws: String = pieces
.frameworks
.iter()
.map(|f| format!(".linkedFramework(\"{f}\"), "))
.collect();
format!(", linkerSettings: [{fws}]")
};
let mut entries: Vec<&str> = Vec::new();
if has_resources {
entries.push(".process(\"Media.xcassets\")");
}
if has_fonts {
entries.push(".copy(\"fonts\")");
}
let resources = if entries.is_empty() {
String::new()
} else {
format!(", resources: [{}]", entries.join(", "))
};
let product_type = if static_product {
"type: .static, "
} else {
""
};
format!(
"// swift-tools-version:5.9\n\
// Generated by `day build` from standalone pieces' [package.metadata.day.{meta_key}]. Do not edit.\n\
import PackageDescription\n\n\
let package = Package(\n\
\x20 name: \"DayPieces\",\n\
\x20 platforms: [{platform_clause}],\n\
\x20 products: [.library(name: \"DayPieces\", {product_type}targets: [\"DayPieces\"])],\n\
\x20 dependencies: [\n{deps} ],\n\
\x20 targets: [\n\
\x20 .target(name: \"DayPieces\", dependencies: [\n{products} ], path: \"Sources/DayPieces\"{resources}{linker}),\n\
\x20 ]\n\
)\n"
)
}
pub struct MacosSwift {
pub package: std::path::PathBuf,
pub frameworks: Vec<String>,
pub platform: String,
}
pub fn write_macos_pieces(
project: &Project,
keep_empty: bool,
) -> Result<Option<MacosSwift>, String> {
let pieces = resolve_apple(project, &["appkit"], "macos").unwrap_or_else(|e| {
eprintln!("day: macOS piece discovery failed ({e}); building without Swift contributions");
ApplePieces::default()
});
let pkg_dir = project.root.join("build/day/macos/DayPieces");
if pieces.swift_dirs.is_empty() && pieces.packages.is_empty() && !keep_empty {
let _ = std::fs::remove_dir_all(&pkg_dir);
return Ok(None);
}
let sources = pkg_dir.join("Sources/DayPieces");
std::fs::create_dir_all(&sources).map_err(|e| e.to_string())?;
let mut expected: Vec<std::path::PathBuf> = Vec::new();
let placeholder = sources.join("_DayPieces.swift");
write_if_changed(
&placeholder,
"// Generated by `day build`. The DayPieces local package aggregates every standalone piece's\n\
// macOS Swift shims and SwiftPM package dependencies (docs/extending.md). Do not edit.\n\
enum _DayPieces {}\n",
)?;
expected.push(placeholder);
for (namespace, dir) in &pieces.swift_dirs {
sync_swift_dir(Path::new(dir), &sources.join(namespace), &mut expected)?;
}
stage_bridge_swift(project, "macos", &sources, &mut expected)?;
if let Some(glue) = render_view_glue(&pieces) {
let path = sources.join("_DayViews.swift");
write_if_changed(&path, &glue)?;
expected.push(path);
}
prune_except(&sources, &expected.into_iter().collect());
let platform = max_platform("13.0", pieces.platform.as_deref().unwrap_or("13.0"));
write_if_changed(
&pkg_dir.join("Package.swift"),
&package_swift(
&pieces,
"macos",
&format!(".macOS(\"{platform}\")"),
true,
false,
false,
),
)?;
Ok(Some(MacosSwift {
package: pkg_dir,
frameworks: pieces.frameworks,
platform,
}))
}
pub(crate) fn dependency_roots(project: &Project) -> Vec<(String, std::path::PathBuf)> {
let Ok(meta) = cargo_metadata_all_features(project) else {
return Vec::new();
};
meta.packages
.iter()
.filter_map(|pkg| {
Path::new(&pkg.manifest_path)
.parent()
.map(|dir| (pkg.name.clone(), dir.to_path_buf()))
})
.collect()
}
fn stage_bridge_swift(
project: &Project,
platform: &str,
sources: &Path,
expected: &mut Vec<std::path::PathBuf>,
) -> Result<(), String> {
crate::bridge::stage(project, platform).write_swift(sources, expected)
}
pub(crate) fn write_if_changed(path: &Path, content: &str) -> Result<(), String> {
write_bytes_if_changed(path, content.as_bytes())
}
pub(crate) fn write_bytes_if_changed(path: &Path, content: &[u8]) -> Result<(), String> {
if std::fs::read(path).is_ok_and(|cur| cur == content) {
return Ok(());
}
std::fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))
}
pub(crate) fn copy_if_changed(src: &Path, dest: &Path) -> Result<(), String> {
let content = std::fs::read(src).map_err(|e| format!("{}: {e}", src.display()))?;
write_bytes_if_changed(dest, &content)
}
pub(crate) fn collect_files(root: &Path, expected: &mut Vec<std::path::PathBuf>) {
let Ok(rd) = std::fs::read_dir(root) else {
return;
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
collect_files(&path, expected);
} else {
expected.push(path);
}
}
}
fn sync_swift_dir(
src: &Path,
dest: &Path,
expected: &mut Vec<std::path::PathBuf>,
) -> Result<(), String> {
std::fs::create_dir_all(dest).map_err(|e| e.to_string())?;
let rd = std::fs::read_dir(src).map_err(|e| format!("{}: {e}", src.display()))?;
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
sync_swift_dir(&path, &dest.join(entry.file_name()), expected)?;
} else if path.extension().and_then(|e| e.to_str()) == Some("swift") {
let content =
std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let target = dest.join(entry.file_name());
write_if_changed(&target, &content)?;
expected.push(target);
}
}
Ok(())
}
pub(crate) fn prune_except(root: &Path, expected: &HashSet<std::path::PathBuf>) {
for entry in std::fs::read_dir(root).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
prune_except(&path, expected);
if std::fs::read_dir(&path).is_ok_and(|mut d| d.next().is_none()) {
let _ = std::fs::remove_dir(&path);
}
} else if !expected.contains(&path) {
let _ = std::fs::remove_file(&path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permissions::AndroidRaw;
fn raw(name: &str) -> AndroidRaw {
AndroidRaw {
name: name.to_string(),
max_sdk: None,
}
}
#[test]
fn overlay_without_components_is_unchanged() {
let xml = pieces_manifest(&[raw("android.permission.INTERNET")], &[]);
assert!(xml.contains("<uses-permission android:name=\"android.permission.INTERNET\" />"));
assert!(!xml.contains("<application"));
assert!(xml.trim_end().ends_with("</manifest>"));
}
#[test]
fn components_are_wrapped_in_application_and_indented() {
let frag = "<receiver android:name=\"dev.daybrite.day.notify.DayNotifyAlarmReceiver\"\n android:exported=\"false\" />";
let xml = pieces_manifest(
&[raw("android.permission.RECEIVE_BOOT_COMPLETED")],
&[frag.to_string()],
);
assert!(xml.contains(" <application>\n"));
assert!(xml.contains(" </application>\n"));
assert!(
xml.contains(
" <receiver android:name=\"dev.daybrite.day.notify.DayNotifyAlarmReceiver\""
),
"fragment should be indented inside <application>:\n{xml}"
);
let perm_at = xml.find("<uses-permission").expect("permission present");
let app_at = xml.find("<application").expect("application present");
assert!(perm_at < app_at);
}
#[test]
fn components_alone_still_produce_an_overlay() {
let xml = pieces_manifest(&[], &["<service android:name=\"a.B\" />".to_string()]);
assert!(xml.contains("<service android:name=\"a.B\" />"));
assert!(!xml.contains("<uses-permission"));
}
#[test]
fn wrapper_in_a_fragment_is_rejected() {
let dir = std::env::temp_dir().join("day-pieces-frag-wrapper");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("components.xml");
std::fs::write(
&path,
"<manifest><application><receiver android:name=\"a.B\" /></application></manifest>",
)
.unwrap();
let err = read_manifest_components(&[path.to_string_lossy().into_owned()])
.expect_err("a wrapped fragment must be rejected");
assert!(err.contains("wrapper"), "unhelpful error: {err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn wrapper_named_only_inside_a_comment_is_accepted() {
let dir = std::env::temp_dir().join("day-pieces-frag-comment");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("components.xml");
std::fs::write(
&path,
"<!-- Only the elements that go inside <application>; no <manifest> wrapper. -->\n\
<receiver android:name=\"a.B\" android:exported=\"false\" />",
)
.unwrap();
let out = read_manifest_components(&[path.to_string_lossy().into_owned()])
.expect("a comment mentioning the wrapper must not be rejected");
assert!(out[0].contains("<receiver"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unterminated_comment_does_not_hide_a_real_wrapper() {
assert!(!strip_xml_comments("<!-- oops <application>").contains("<application"));
assert!(strip_xml_comments("<!-- c --><application>").contains("<application"));
}
#[test]
fn empty_fragment_is_rejected() {
let dir = std::env::temp_dir().join("day-pieces-frag-empty");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("components.xml");
std::fs::write(&path, " \n\t\n").unwrap();
let err = read_manifest_components(&[path.to_string_lossy().into_owned()])
.expect_err("an empty fragment must be rejected");
assert!(err.contains("empty"), "unhelpful error: {err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn android_meta_parses_the_new_key() {
let meta: AndroidMeta = toml::from_str(
"java = \"android/java\"\nmanifest-components = [\"android/components.xml\"]\n",
)
.expect("parses");
assert_eq!(meta.java.0, vec!["android/java".to_string()]);
assert_eq!(
meta.manifest_components.0,
vec!["android/components.xml".to_string()]
);
}
#[test]
fn android_meta_without_the_key_still_parses() {
let meta: AndroidMeta =
toml::from_str("java = [\"android/java\"]\npermissions = []\n").expect("parses");
assert!(meta.manifest_components.0.is_empty());
}
#[test]
fn max_platform_compares_numerically() {
assert_eq!(max_platform("15.0", "16.0"), "16.0");
assert_eq!(max_platform("16.0", "15.4"), "16.0");
assert_eq!(max_platform("9.9", "10.0"), "10.0");
assert_eq!(max_platform("13.0", "13.0"), "13.0");
assert_eq!(max_platform("13.0.1", "13.0"), "13.0.1");
}
#[test]
fn apple_meta_parses_with_and_without_the_new_keys() {
let old: AppleMeta = toml::from_str(
"swift = [\"ios/swift\"]\n\
swift-packages = [{ url = \"https://github.com/airbnb/lottie-ios\", from = \"4.5.0\", products = [\"Lottie\"] }]\n",
)
.expect("parses");
assert!(old.platform.is_none());
assert_eq!(old.swift_packages[0].identity(), "lottie-ios");
assert_eq!(old.swift_packages[0].product_names(), vec!["Lottie"]);
let new: AppleMeta =
toml::from_str("platform = \"16.0\"\nswift-packages = [{ path = \"swiftui\" }]\n")
.expect("parses");
assert_eq!(new.platform.as_deref(), Some("16.0"));
let pkg = &new.swift_packages[0];
assert_eq!(pkg.identity(), "swiftui");
assert_eq!(pkg.product_names(), vec!["swiftui"]);
assert_eq!(pkg.dependency_clause(), ".package(path: \"swiftui\")");
}
#[test]
fn ios_target_parses_from_the_scaffold_pbxproj() {
let root = concat!(env!("CARGO_MANIFEST_DIR"), "/templates/app/platform/ios");
let pbx = std::fs::read_to_string(format!("{root}/DayApp.xcodeproj/project.pbxproj"))
.expect("template pbxproj");
assert_eq!(ios_target_from_pbxproj(&pbx), None);
let xcc =
std::fs::read_to_string(format!("{root}/DayApp.xcconfig")).expect("template xcconfig");
assert_eq!(ios_target_from_pbxproj(&xcc).as_deref(), Some("15.0"));
let raw = " IPHONEOS_DEPLOYMENT_TARGET = 15.0;\n\tIPHONEOS_DEPLOYMENT_TARGET=16.0 ;\n";
assert_eq!(ios_target_from_pbxproj(raw).as_deref(), Some("16.0"));
assert_eq!(ios_target_from_pbxproj("nothing here"), None);
}
#[test]
fn macos_package_swift_is_static_with_local_deps() {
let pieces = ApplePieces {
packages: vec![SwiftPackage {
url: None,
path: Some("/abs/swiftui".into()),
from: None,
exact: None,
branch: None,
revision: None,
products: vec![],
}],
frameworks: vec!["UserNotifications".into()],
..Default::default()
};
let text = package_swift(&pieces, "macos", ".macOS(\"13.0\")", true, false, false);
assert!(text.contains("platforms: [.macOS(\"13.0\")]"));
assert!(
text.contains(".library(name: \"DayPieces\", type: .static, targets: [\"DayPieces\"])")
);
assert!(text.contains(".package(path: \"/abs/swiftui\"),"));
assert!(text.contains(".product(name: \"swiftui\", package: \"swiftui\"),"));
assert!(text.contains(".linkedFramework(\"UserNotifications\")"));
assert!(text.contains("[package.metadata.day.macos]"));
}
#[test]
fn ios_package_swift_keeps_its_shipped_shape() {
let pieces = ApplePieces {
packages: vec![SwiftPackage {
url: Some("https://github.com/airbnb/lottie-ios".into()),
path: None,
from: Some("4.5.0".into()),
exact: None,
branch: None,
revision: None,
products: vec!["Lottie".into()],
}],
..Default::default()
};
let text = package_swift(&pieces, "ios", ".iOS(\"15.0\")", false, true, false);
assert!(text.contains("platforms: [.iOS(\"15.0\")]"));
assert!(text.contains(".library(name: \"DayPieces\", targets: [\"DayPieces\"])"));
assert!(
text.contains(
".package(url: \"https://github.com/airbnb/lottie-ios\", from: \"4.5.0\"),"
)
);
assert!(text.contains(".product(name: \"Lottie\", package: \"lottie-ios\"),"));
assert!(text.contains(".process(\"Media.xcassets\")"));
assert!(text.contains("[package.metadata.day.ios]"));
}
}