day-cli 0.1.2

Declarative app development API using native UI toolkits
//! ios-uikit → App Store .ipa via `xcodebuild archive` + `-exportArchive` (arm64-only device
//! build; automatic signing with an App Store Connect API key — the Tauri/Flutter CI path).
//! Without `signing.ios` config this degrades LOUDLY to an UNSIGNED device .ipa
//! (`-unsigned.ipa`): a real `-sdk iphoneos` Release build with code signing disabled,
//! packaged as `Payload/<App>.app`. It cannot launch as-is — the developer signs it
//! (codesign / Xcode's Devices window) or sideloads it with AltStore/SideStore, which re-sign
//! every binary with the user's own Apple ID anyway (§16.5).

use std::path::{Path, PathBuf};
use std::process::Command;

use super::settings::{PackOptions, resolve_degradable};
use super::{Artifact, PackError, SignTier, run_tool};
use crate::meta::Project;
use crate::ops::status;
use crate::targets::Target;

pub fn pack(
    project: &Project,
    _target: &'static Target,
    opts: &PackOptions,
    dist: &Path,
) -> Result<Artifact, PackError> {
    let ios = project
        .manifest
        .signing
        .as_ref()
        .and_then(|s| s.ios.as_ref());
    if opts.no_sign || ios.is_none() {
        if ios.is_none() {
            status(
                "Warning",
                "no signing.ios config — packing an UNSIGNED device .ipa (sideload it with \
                 AltStore/SideStore or sign it yourself; a signed .ipa needs signing.ios: \
                 {team, key-id, issuer, key-path})",
            );
        }
        return unsigned_ipa(project, opts, dist);
    }
    let ios = ios.unwrap();

    // A team that references an unset secret degrades to the unsigned device .ipa (§20).
    let Some(team) = resolve_degradable(&ios.team, "signing.ios.team").map_err(PackError::Sign)?
    else {
        status(
            "Warning",
            "signing.ios.team unresolved — packing an UNSIGNED device .ipa instead of a signed one",
        );
        return unsigned_ipa(project, opts, dist);
    };
    let method = ios
        .export_method
        .clone()
        .unwrap_or_else(|| "app-store-connect".into());
    // ASC API key: all-or-nothing triple; without it xcodebuild uses the local Xcode account session.
    let key_id = resolve_field(ios.key_id.as_ref(), "signing.ios.key-id")?;
    let issuer = resolve_field(ios.issuer.as_ref(), "signing.ios.issuer")?;
    let key_path = resolve_field(ios.key_path.as_ref(), "signing.ios.key-path")?;
    let asc = match (key_id, issuer, key_path) {
        (Some(k), Some(i), Some(p)) => {
            if !Path::new(&p).exists() {
                return Err(PackError::Sign(format!("ASC key file not found: {p}")));
            }
            Some((k, i, p))
        }
        (None, None, None) => None,
        _ => {
            return Err(PackError::Sign(
                "signing.ios: key-id, issuer and key-path must be set together".into(),
            ));
        }
    };

    let name = &project.manifest.app.name;
    let version = &project.manifest.app.version;
    let title = project
        .manifest
        .app
        .title
        .clone()
        .unwrap_or_else(|| name.clone());

    // The DayPieces SwiftPM package must exist before xcodebuild resolves the project.
    crate::mobile::prepare_ios(project).map_err(PackError::Other)?;
    ensure_shared_scheme(project).map_err(PackError::Other)?;

    let build_dir = project.root.join("build/day/ios-uikit");
    let archive = build_dir.join(format!("{title}.xcarchive"));
    let _ = std::fs::remove_dir_all(&archive);
    let day_bin = std::env::current_exe().map_err(|e| PackError::Other(e.to_string()))?;

    // --- archive (device, Release, automatic signing) -----------------------
    status(
        "Building",
        "ios-uikit (xcodebuild archive, generic/platform=iOS)",
    );
    let mut cmd = Command::new("xcodebuild");
    cmd.current_dir(project.root.join("platform/ios"))
        .args(["-project", "DayApp.xcodeproj", "-scheme", "Runner"])
        .args(["-configuration", "Release"])
        .args(["-destination", "generic/platform=iOS"])
        .arg("-archivePath")
        .arg(&archive)
        .arg("-derivedDataPath")
        .arg(build_dir.join("archive-dd"))
        .arg("-allowProvisioningUpdates")
        // The scaffold pbxproj disables signing for simulator development — the archive build
        // re-enables it from the command line (command-line settings override the project).
        .arg("CODE_SIGNING_ALLOWED=YES")
        .arg("CODE_SIGN_STYLE=Automatic")
        .arg("CODE_SIGN_IDENTITY=Apple Development")
        .arg(format!("DEVELOPMENT_TEAM={team}"))
        .arg(format!("MARKETING_VERSION={version}"))
        .arg(format!(
            "CURRENT_PROJECT_VERSION={}",
            project.manifest.app.build
        ))
        .arg(format!("DAY_BIN={}", day_bin.display()))
        .arg("archive");
    if let Some((k, i, p)) = &asc {
        cmd.args(["-authenticationKeyID", k])
            .args(["-authenticationKeyIssuerID", i])
            .arg("-authenticationKeyPath")
            .arg(std::fs::canonicalize(p).unwrap_or_else(|_| PathBuf::from(p)));
    }
    run_tool(&mut cmd, "xcodebuild archive").map_err(PackError::Sign)?;

    // --- export (.ipa) -------------------------------------------------------
    let export_plist = build_dir.join("ExportOptions.plist");
    std::fs::write(&export_plist, export_options(&method, &team))
        .map_err(|e| PackError::Other(e.to_string()))?;
    let export_dir = build_dir.join("export");
    let _ = std::fs::remove_dir_all(&export_dir);
    status("Packing", &format!("xcodebuild -exportArchive ({method})"));
    let mut cmd = Command::new("xcodebuild");
    cmd.current_dir(project.root.join("platform/ios"))
        .arg("-exportArchive")
        .arg("-archivePath")
        .arg(&archive)
        .arg("-exportPath")
        .arg(&export_dir)
        .arg("-exportOptionsPlist")
        .arg(&export_plist)
        .arg("-allowProvisioningUpdates");
    if let Some((k, i, p)) = &asc {
        cmd.args(["-authenticationKeyID", k])
            .args(["-authenticationKeyIssuerID", i])
            .arg("-authenticationKeyPath")
            .arg(std::fs::canonicalize(p).unwrap_or_else(|_| PathBuf::from(p)));
    }
    run_tool(&mut cmd, "xcodebuild -exportArchive").map_err(PackError::Sign)?;

    let ipa = std::fs::read_dir(&export_dir)
        .map_err(|e| PackError::Other(e.to_string()))?
        .flatten()
        .map(|e| e.path())
        .find(|p| p.extension().and_then(|x| x.to_str()) == Some("ipa"))
        .ok_or_else(|| {
            PackError::Other(format!("no .ipa exported under {}", export_dir.display()))
        })?;
    let out = dist.join(format!("{name}{}.ipa", opts.version_tag(version)));
    std::fs::copy(&ipa, &out).map_err(|e| PackError::Other(e.to_string()))?;
    Ok(Artifact {
        path: out,
        kind: "ipa",
        sha256: String::new(),
        tier: SignTier::Release,
    })
}

/// A configured-but-optional signing field: absent config stays None; an unset secret degrades.
fn resolve_field(raw: Option<&String>, what: &str) -> Result<Option<String>, PackError> {
    match raw {
        None => Ok(None),
        Some(r) => resolve_degradable(r, what).map_err(PackError::Sign),
    }
}

/// ExportOptions for automatic signing; Xcode ≥15.4 names ("app-store-connect", "release-testing").
pub(crate) fn export_options(method: &str, team: &str) -> String {
    format!(
        r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>method</key><string>{method}</string>
  <key>teamID</key><string>{team}</string>
  <key>signingStyle</key><string>automatic</string>
  <key>uploadSymbols</key><true/>
  <key>destination</key><string>export</string>
</dict></plist>
"#
    )
}

/// `xcodebuild archive` needs a scheme (targets aren't archivable). The scaffold ships none — the
/// pbxproj carries stable synthetic ids, so generate a shared Runner scheme on demand, parsing the
/// native-target id and product name out of the pbxproj.
fn ensure_shared_scheme(project: &Project) -> Result<(), String> {
    let xcodeproj = project.root.join("platform/ios/DayApp.xcodeproj");
    let scheme = xcodeproj.join("xcshareddata/xcschemes/Runner.xcscheme");
    if scheme.exists() {
        return Ok(());
    }
    let pbxproj = std::fs::read_to_string(xcodeproj.join("project.pbxproj"))
        .map_err(|e| format!("read pbxproj: {e}"))?;
    let target_id =
        find_native_target_id(&pbxproj).ok_or("no PBXNativeTarget found in project.pbxproj")?;
    let product = pbxproj
        .lines()
        .find(|l| l.contains("explicitFileType = wrapper.application"))
        .and_then(|l| l.split("path = ").nth(1))
        .and_then(|s| s.split(';').next())
        .map(str::trim)
        .ok_or("no application product reference in project.pbxproj")?;
    std::fs::create_dir_all(scheme.parent().unwrap()).map_err(|e| e.to_string())?;
    let xml = format!(
        r#"<?xml version="1.0" encoding="UTF-8"?>
<Scheme LastUpgradeVersion="1500" version="1.7">
  <BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
    <BuildActionEntries>
      <BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
        <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{target_id}" BuildableName="{product}" BlueprintName="Runner" ReferencedContainer="container:DayApp.xcodeproj"/>
      </BuildActionEntry>
    </BuildActionEntries>
  </BuildAction>
  <ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
  <LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">
    <BuildableProductRunnable runnableDebuggingMode="0">
      <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{target_id}" BuildableName="{product}" BlueprintName="Runner" ReferencedContainer="container:DayApp.xcodeproj"/>
    </BuildableProductRunnable>
  </LaunchAction>
</Scheme>
"#
    );
    std::fs::write(&scheme, xml).map_err(|e| e.to_string())?;
    status("Generated", &format!("{}", scheme.display()));
    Ok(())
}

/// The 24-hex-digit object id of the first PBXNativeTarget (`XXXX /* Name */ = {` whose next line
/// declares `isa = PBXNativeTarget`).
fn find_native_target_id(pbxproj: &str) -> Option<String> {
    let mut lines = pbxproj.lines().peekable();
    while let Some(line) = lines.next() {
        let t = line.trim();
        if let Some(rest) = t.strip_suffix("= {")
            && let Some(id) = rest.split_whitespace().next()
            && id.len() == 24
            && id.chars().all(|c| c.is_ascii_hexdigit())
            && lines
                .peek()
                .is_some_and(|n| n.trim() == "isa = PBXNativeTarget;")
        {
            return Some(id.to_string());
        }
    }
    None
}

/// The unsigned fallback: a real DEVICE build (`-sdk iphoneos`, Release) with code signing
/// disabled, packaged as `Payload/<App>.app` inside a `-unsigned.ipa`. It cannot launch until
/// signed — AltStore/SideStore re-sign it with the user's own Apple ID on install, or the
/// developer signs it directly (codesign / Xcode's Devices window).
fn unsigned_ipa(project: &Project, opts: &PackOptions, dist: &Path) -> Result<Artifact, PackError> {
    let name = &project.manifest.app.name;
    let version = &project.manifest.app.version;

    // The same pre-build staging the signed path (and build_ios) performs.
    crate::mobile::prepare_ios(project).map_err(PackError::Other)?;

    let build_dir = project.root.join("build/day/ios-uikit");
    let symroot = build_dir.join("pack-unsigned");
    let day_bin = std::env::current_exe().map_err(|e| PackError::Other(e.to_string()))?;
    status("Building", "ios-uikit (xcodebuild, iphoneos, unsigned)");
    run_tool(
        Command::new("xcodebuild")
            .current_dir(project.root.join("platform/ios"))
            .args(["-project", "DayApp.xcodeproj", "-target", "Runner"])
            .args(["-configuration", "Release", "-sdk", "iphoneos"])
            .args(["-arch", "arm64"])
            .arg(format!("SYMROOT={}", symroot.display()))
            .arg("CODE_SIGNING_ALLOWED=NO")
            .arg("CODE_SIGNING_REQUIRED=NO")
            .arg(format!("MARKETING_VERSION={version}"))
            .arg(format!(
                "CURRENT_PROJECT_VERSION={}",
                project.manifest.app.build
            ))
            .arg(format!("DAY_BIN={}", day_bin.display()))
            .arg("build"),
        "xcodebuild (iphoneos, unsigned)",
    )
    .map_err(PackError::Other)?;

    let products = symroot.join("Release-iphoneos");
    let app = std::fs::read_dir(&products)
        .map_err(|e| PackError::Other(format!("reading {}: {e}", products.display())))?
        .flatten()
        .map(|e| e.path())
        .find(|p| p.extension().and_then(|x| x.to_str()) == Some("app"))
        .ok_or_else(|| PackError::Other(format!("no .app bundle in {}", products.display())))?;

    // .ipa layout: a zip whose root holds Payload/<App>.app.
    let staging = build_dir.join("ipa-staging");
    let payload = staging.join("Payload");
    let _ = std::fs::remove_dir_all(&staging);
    std::fs::create_dir_all(&payload).map_err(|e| PackError::Other(e.to_string()))?;
    run_tool(
        Command::new("ditto")
            .arg(&app)
            .arg(payload.join(app.file_name().unwrap_or_default())),
        "ditto stage",
    )
    .map_err(PackError::Other)?;
    let out = dist.join(format!("{name}{}-unsigned.ipa", opts.version_tag(version)));
    let _ = std::fs::remove_file(&out);
    run_tool(
        Command::new("ditto")
            .args(["-c", "-k"])
            .arg(&staging)
            .arg(&out),
        "ditto zip",
    )
    .map_err(PackError::Other)?;
    Ok(Artifact {
        path: out,
        kind: "ipa-unsigned",
        sha256: String::new(),
        tier: SignTier::Unsigned,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn export_options_plist_shape() {
        let plist = export_options("app-store-connect", "TEAM123");
        assert!(plist.contains("<key>method</key><string>app-store-connect</string>"));
        assert!(plist.contains("<key>teamID</key><string>TEAM123</string>"));
        assert!(plist.contains("<key>signingStyle</key><string>automatic</string>"));
    }

    #[test]
    fn native_target_id_from_pbxproj() {
        let pbx = "\t\tDA0000000000000000000020 /* Runner */ = {\n\t\t\tisa = PBXNativeTarget;\n";
        assert_eq!(
            find_native_target_id(pbx).as_deref(),
            Some("DA0000000000000000000020")
        );
        assert_eq!(find_native_target_id("nothing here"), None);
    }
}