use crate::meta::Project;
use crate::ops::status;
const REF_ID: &str = "DA0000000000000000000006";
const MOVED: [&str; 7] = [
"CODE_SIGNING_ALLOWED",
"CODE_SIGN_IDENTITY",
"CURRENT_PROJECT_VERSION",
"MARKETING_VERSION",
"PRODUCT_BUNDLE_IDENTIFIER",
"TARGETED_DEVICE_FAMILY",
"IPHONEOS_DEPLOYMENT_TARGET", ];
fn target_for(platform: &str) -> &'static str {
if platform == "macos" {
"macos-appkit"
} else {
"ios-uikit"
}
}
pub fn write_generated(project: &Project, platform: &str) -> Result<(), String> {
let resolved = project.manifest.resolve(target_for(platform));
let out = format!(
"// Generated by `day build` from Day.toml — do not edit (change Day.toml [app] or\n\
// Cargo.toml [package] instead). platform/{platform}/DayApp.xcconfig includes this last.\n\
PRODUCT_BUNDLE_IDENTIFIER = {}\n\
MARKETING_VERSION = {}\n\
CURRENT_PROJECT_VERSION = {}\n\
DAY_URL_SCHEME = {}\n",
resolved.id,
resolved.version,
resolved.build,
resolved.scheme()
);
let dir = project.root.join("build/day/xcconfig");
std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {e}", dir.display()))?;
let path = dir.join(format!("{platform}.xcconfig"));
if std::fs::read_to_string(&path).ok().as_deref() != Some(out.as_str()) {
std::fs::write(&path, out).map_err(|e| format!("{}: {e}", path.display()))?;
}
Ok(())
}
#[derive(Default)]
struct Extracted {
id: Option<String>,
version: Option<String>,
build: Option<String>,
device_family: Option<String>,
deployment: Option<String>,
}
pub fn ensure_split(project: &Project, platform: &str) -> Result<(), String> {
let dir = project.root.join("platform").join(platform);
let pbx_path = dir.join("DayApp.xcodeproj/project.pbxproj");
if !pbx_path.exists() {
return Ok(());
}
let xcc_path = dir.join("DayApp.xcconfig");
let pbx =
std::fs::read_to_string(&pbx_path).map_err(|e| format!("{}: {e}", pbx_path.display()))?;
let wired = pbx.contains("DayApp.xcconfig");
if wired && xcc_path.exists() {
return Ok(());
}
if !wired && pbx.contains("baseConfigurationReference") {
status(
"Warning",
&format!(
"platform/{platform}: the pbxproj already uses a custom xcconfig — skipping \
the DayApp.xcconfig split"
),
);
return Ok(());
}
let deployment_key = if platform == "macos" {
"MACOSX_DEPLOYMENT_TARGET"
} else {
"IPHONEOS_DEPLOYMENT_TARGET"
};
let (new_pbx, extracted) = if wired {
(None, Extracted::default())
} else {
match split_pbxproj(&pbx, deployment_key) {
Ok((text, ex)) => (Some(text), ex),
Err(why) => {
status(
"Warning",
&format!(
"platform/{platform}: {why} — build settings stay in the pbxproj. \
To adopt the split by hand, see the scaffold's DayApp.xcconfig \
(`day new`) and its project.pbxproj wiring."
),
);
return Ok(());
}
}
};
if !xcc_path.exists() {
let content = render_user_xcconfig(project, platform, &extracted)?;
std::fs::write(&xcc_path, content).map_err(|e| format!("{}: {e}", xcc_path.display()))?;
}
if let Some(text) = new_pbx {
std::fs::write(&pbx_path, text).map_err(|e| format!("{}: {e}", pbx_path.display()))?;
}
status(
"Splitting",
&format!("platform/{platform} build settings → DayApp.xcconfig (edit settings there)"),
);
Ok(())
}
fn split_pbxproj(text: &str, deployment_key: &str) -> Result<(String, Extracted), String> {
if text.contains(REF_ID) {
return Err(format!("pbxproj already uses object id {REF_ID}"));
}
let file_ref_end = "/* End PBXFileReference section */";
let products_child = "\t\t\t\tDA0000000000000000000012 /* Products */,";
let cfg_head = "isa = XCBuildConfiguration;\n\t\t\tbuildSettings = {";
for (anchor, what) in [
(file_ref_end, "PBXFileReference section"),
(products_child, "main-group Products entry"),
(cfg_head, "XCBuildConfiguration blocks"),
] {
if !text.contains(anchor) {
return Err(format!("no {what} at the scaffold's expected shape"));
}
}
let mut moved: Vec<&str> = MOVED.to_vec();
if !moved.contains(&deployment_key) {
moved.push(deployment_key);
}
let mut extracted = Extracted::default();
let mut kept = String::with_capacity(text.len());
for line in text.lines() {
let trimmed = line.trim();
let key = trimmed.split(" = ").next().unwrap_or("");
if moved.contains(&key) {
let value = trimmed
.split_once(" = ")
.map(|(_, v)| v.trim_end_matches(';').trim_matches('"').to_string())
.unwrap_or_default();
match key {
"PRODUCT_BUNDLE_IDENTIFIER" => extracted.id = Some(value),
"MARKETING_VERSION" => extracted.version = Some(value),
"CURRENT_PROJECT_VERSION" => extracted.build = Some(value),
"TARGETED_DEVICE_FAMILY" => extracted.device_family = Some(value),
k if k == deployment_key => extracted.deployment = Some(value),
_ => {}
}
continue;
}
kept.push_str(line);
kept.push('\n');
}
let with_base = kept.replace(
cfg_head,
&format!(
"isa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = {REF_ID} \
/* DayApp.xcconfig */;\n\t\t\tbuildSettings = {{"
),
);
let with_ref = with_base.replacen(
file_ref_end,
&format!(
"\t\t{REF_ID} /* DayApp.xcconfig */ = {{isa = PBXFileReference; lastKnownFileType = \
text.xcconfig; path = DayApp.xcconfig; sourceTree = \"<group>\"; }};\n{file_ref_end}"
),
1,
);
let with_group = with_ref.replacen(
products_child,
&format!("\t\t\t\t{REF_ID} /* DayApp.xcconfig */,\n{products_child}"),
1,
);
Ok((with_group, extracted))
}
fn render_user_xcconfig(
project: &Project,
platform: &str,
extracted: &Extracted,
) -> Result<String, String> {
let path = format!("platform/{platform}/DayApp.xcconfig");
let template = crate::template::builtin_app()
.into_iter()
.find(|f| f.path == path)
.and_then(|f| String::from_utf8(f.bytes).ok())
.ok_or_else(|| format!("embedded template {path} missing"))?;
let id = extracted
.id
.clone()
.unwrap_or_else(|| project.manifest.resolve(target_for(platform)).id);
let mut out = template.replace("{{id}}", &id);
let defaults = [
(
"MARKETING_VERSION = 0.1.0",
"MARKETING_VERSION",
&extracted.version,
),
(
"CURRENT_PROJECT_VERSION = 1",
"CURRENT_PROJECT_VERSION",
&extracted.build,
),
(
"IPHONEOS_DEPLOYMENT_TARGET = 15.0",
"IPHONEOS_DEPLOYMENT_TARGET",
&extracted.deployment,
),
(
"MACOSX_DEPLOYMENT_TARGET = 13.0",
"MACOSX_DEPLOYMENT_TARGET",
&extracted.deployment,
),
(
"TARGETED_DEVICE_FAMILY = 1,2",
"TARGETED_DEVICE_FAMILY",
&extracted.device_family,
),
];
for (default_line, key, value) in defaults {
if let Some(v) = value {
out = out.replacen(default_line, &format!("{key} = {v}"), 1);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
const PRE_SPLIT: &str = "// !$*UTF8*$!\n\
/* Begin PBXFileReference section */\n\
\t\tDA0000000000000000000005 /* Assets.xcassets */ = {isa = PBXFileReference; };\n\
/* End PBXFileReference section */\n\
\t\t\t\tDA0000000000000000000012 /* Products */,\n\
\t\tDA0000000000000000000060 /* Debug */ = {\n\
\t\t\tisa = XCBuildConfiguration;\n\
\t\t\tbuildSettings = {\n\
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 16.0;\n\
\t\t\t\tSDKROOT = iphoneos;\n\
\t\t\t};\n\
\t\t};\n\
\t\tDA0000000000000000000062 /* Debug */ = {\n\
\t\t\tisa = XCBuildConfiguration;\n\
\t\t\tbuildSettings = {\n\
\t\t\t\tCODE_SIGNING_ALLOWED = NO;\n\
\t\t\t\tMARKETING_VERSION = 2.3.4;\n\
\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.app;\n\
\t\t\t\tPRODUCT_NAME = Example;\n\
\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\
\t\t\t};\n\
\t\t};\n";
#[test]
fn splits_and_extracts() {
let (out, ex) = split_pbxproj(PRE_SPLIT, "IPHONEOS_DEPLOYMENT_TARGET").expect("split");
assert_eq!(out.matches("baseConfigurationReference").count(), 2);
assert_eq!(
out.matches("/* DayApp.xcconfig */ = {isa = PBXFileReference")
.count(),
1
);
assert_eq!(
out.matches("\t\t\t\tDA0000000000000000000006 /* DayApp.xcconfig */,")
.count(),
1
);
assert!(!out.contains("MARKETING_VERSION"));
assert!(!out.contains("IPHONEOS_DEPLOYMENT_TARGET"));
assert!(out.contains("PRODUCT_NAME = Example;"));
assert!(out.contains("SDKROOT = iphoneos;"));
assert_eq!(ex.id.as_deref(), Some("com.example.app"));
assert_eq!(ex.version.as_deref(), Some("2.3.4"));
assert_eq!(ex.deployment.as_deref(), Some("16.0"));
assert_eq!(ex.device_family.as_deref(), Some("1,2"));
}
#[test]
fn refuses_when_id_taken_or_shape_unknown() {
let taken = PRE_SPLIT.replace("DA0000000000000000000005", "DA0000000000000000000006");
assert!(split_pbxproj(&taken, "IPHONEOS_DEPLOYMENT_TARGET").is_err());
assert!(split_pbxproj("not a pbxproj", "IPHONEOS_DEPLOYMENT_TARGET").is_err());
}
}