Prepare Apple App Store distribution (#59)

This commit is contained in:
2026-08-16 17:55:59 +02:00
parent b50e47ab73
commit f894d43eb6
46 changed files with 20694 additions and 166 deletions

View File

@@ -0,0 +1,15 @@
[package]
name = "ironstorage-apple-release"
description = "Validate and prepare IronStorage Apple App Store releases"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false
[dependencies]
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
url.workspace = true

View File

@@ -0,0 +1,395 @@
use serde::Deserialize;
use std::{
collections::{BTreeMap, BTreeSet},
env,
error::Error,
ffi::OsString,
fs::{self, OpenOptions},
io::Write,
path::{Path, PathBuf},
};
use url::Url;
const BUNDLE_ID: &str = "de.rfc1437.ironstorage";
#[derive(Deserialize)]
struct Metadata {
app: App,
release: Release,
screenshots: Vec<Screenshot>,
watch_screenshots: Vec<Screenshot>,
content: Content,
}
#[derive(Deserialize)]
struct App {
name: String,
bundle_identifier: String,
developer_name: String,
subtitle: String,
description: String,
keywords: String,
icon_file: String,
category: String,
privacy_url: String,
support_url: String,
marketing_url: String,
age_rating: String,
}
#[derive(Deserialize)]
struct Release {
version: String,
build: String,
minimum_ios: String,
notes: String,
}
#[derive(Deserialize)]
struct Screenshot {
file: String,
caption: String,
width: u32,
height: u32,
}
#[derive(Deserialize)]
struct Content {
advertising: bool,
account_creation: bool,
digital_purchases: bool,
tracking: bool,
user_generated_content: bool,
}
#[derive(Deserialize)]
struct CargoMetadata {
packages: Vec<CargoPackage>,
resolve: CargoResolve,
}
#[derive(Deserialize)]
struct CargoPackage {
id: String,
name: String,
version: String,
license: Option<String>,
license_file: Option<String>,
manifest_path: PathBuf,
source: Option<String>,
repository: Option<String>,
}
#[derive(Deserialize)]
struct CargoResolve {
nodes: Vec<CargoNode>,
}
#[derive(Deserialize)]
struct CargoNode {
id: String,
dependencies: Vec<String>,
}
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<OsString> = env::args_os().collect();
match args.as_slice() {
[_, command, root] if command == "verify" => verify(Path::new(root)),
[_, command, metadata, output] if command == "licenses" => {
write_licenses(Path::new(metadata), Path::new(output))
}
_ => Err("usage:\n ironstorage-apple-release verify <repository-root>\n ironstorage-apple-release licenses <cargo-metadata.json> <output.txt>".into()),
}
}
fn read_metadata(root: &Path) -> Result<Metadata, Box<dyn Error>> {
Ok(toml::from_str(&fs::read_to_string(
root.join("apple/AppStore/metadata.toml"),
)?)?)
}
fn verify(root: &Path) -> Result<(), Box<dyn Error>> {
let metadata = read_metadata(root)?;
if metadata.app.bundle_identifier != BUNDLE_ID {
return Err(format!("main bundle identifier must be {BUNDLE_ID}").into());
}
if metadata.app.age_rating != "4+" || metadata.release.minimum_ios != "17.0" {
return Err(
"release metadata must retain the reviewed 4+ rating and iOS 17.0 minimum".into(),
);
}
if metadata.release.build.parse::<u64>()? == 0 {
return Err("build number must be positive".into());
}
for (name, value) in [
("privacy URL", metadata.app.privacy_url.as_str()),
("support URL", metadata.app.support_url.as_str()),
("marketing URL", metadata.app.marketing_url.as_str()),
] {
require_https(name, value)?;
}
for (name, value) in [
("developer name", metadata.app.developer_name.as_str()),
("subtitle", metadata.app.subtitle.as_str()),
("description", metadata.app.description.as_str()),
("keywords", metadata.app.keywords.as_str()),
("category", metadata.app.category.as_str()),
("release notes", metadata.release.notes.as_str()),
] {
if value.trim().is_empty() {
return Err(format!("{name} must not be empty").into());
}
}
if !metadata.release.notes.contains("Apple Watch") {
return Err("release notes must describe the bundled Apple Watch companion".into());
}
if metadata.content.advertising
|| metadata.content.account_creation
|| metadata.content.digital_purchases
|| metadata.content.tracking
|| metadata.content.user_generated_content
{
return Err("reviewed content declarations must remain false".into());
}
let workspace: toml::Value = toml::from_str(&fs::read_to_string(root.join("Cargo.toml"))?)?;
let workspace_version = workspace["workspace"]["package"]["version"]
.as_str()
.ok_or("workspace version is missing")?;
if metadata.release.version != workspace_version {
return Err("App Store version must match the Rust workspace version".into());
}
let project = fs::read_to_string(root.join("apple/project.yml"))?;
for required in [
"PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.ironstorage\n",
"PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.ironstorage.autofill\n",
"PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.ironstorage.watch\n",
"DEVELOPMENT_TEAM: MU22FMRGK8\n",
"ITSAppUsesNonExemptEncryption: false\n",
"NSFaceIDUsageDescription:",
"NSCameraUsageDescription:",
"- target: IronStorageAutoFill\n embed: true",
"- target: IronStorageWatch\n embed: true",
] {
if !project.contains(required) {
return Err(format!("apple/project.yml is missing {required:?}").into());
}
}
if !project.contains(&format!(
"MARKETING_VERSION: \"{}\"",
metadata.release.version
)) || !project.contains(&format!(
"CURRENT_PROJECT_VERSION: \"{}\"",
metadata.release.build
)) {
return Err("Xcode and App Store version/build metadata differ".into());
}
let privacy = fs::read_to_string(root.join("apple/Resources/App/PrivacyInfo.xcprivacy"))?;
for required in [
"<key>NSPrivacyTracking</key>\n\t<false/>",
"<key>NSPrivacyCollectedDataTypes</key>\n\t<array/>",
] {
if !privacy.contains(required) {
return Err(format!("privacy manifest is missing {required:?}").into());
}
}
let icon = png_size(&root.join(&metadata.app.icon_file))?;
if icon != (1024, 1024) {
return Err(format!("app icon must be 1024x1024, found {}x{}", icon.0, icon.1).into());
}
if metadata.screenshots.len() != 6 {
return Err("exactly six reviewed iPhone screenshots are required".into());
}
if metadata.watch_screenshots.len() != 2 {
return Err("exactly the reviewed Watch list and detail screenshots are required".into());
}
for screenshot in metadata
.screenshots
.iter()
.chain(&metadata.watch_screenshots)
{
if screenshot.caption.trim().is_empty() {
return Err(format!("{} requires a caption", screenshot.file).into());
}
let actual = png_size(&root.join(&screenshot.file))?;
if actual != (screenshot.width, screenshot.height) {
return Err(format!("{} dimensions differ from metadata", screenshot.file).into());
}
}
for required in [
"LICENSE",
"apple/AppStore/privacy/index.html",
"apple/AppStore/DISTRIBUTION.md",
"apple/AppStore/ExportOptions.plist",
"apple/Resources/App/ThirdPartyLicenses.txt",
] {
if !root.join(required).is_file() {
return Err(format!("required release file is missing: {required}").into());
}
}
println!(
"verified {} {} ({}) for {} with {} iPhone and {} Watch screenshots",
metadata.app.name,
metadata.release.version,
metadata.release.build,
metadata.app.bundle_identifier,
metadata.screenshots.len(),
metadata.watch_screenshots.len()
);
Ok(())
}
fn write_licenses(metadata_path: &Path, output: &Path) -> Result<(), Box<dyn Error>> {
let metadata: CargoMetadata = serde_json::from_slice(&fs::read(metadata_path)?)?;
let packages: BTreeMap<_, _> = metadata
.packages
.into_iter()
.map(|package| (package.id.clone(), package))
.collect();
let graph: BTreeMap<_, _> = metadata
.resolve
.nodes
.into_iter()
.map(|node| (node.id, node.dependencies))
.collect();
let root = packages
.values()
.find(|package| package.name == "ironstorage-apple")
.ok_or("cargo metadata has no ironstorage-apple package")?;
let mut pending = vec![root.id.clone()];
let mut reachable = BTreeSet::new();
while let Some(id) = pending.pop() {
if reachable.insert(id.clone()) {
pending.extend(graph.get(&id).into_iter().flatten().cloned());
}
}
let mut package_notices = String::new();
let mut license_texts: BTreeMap<String, Vec<String>> = BTreeMap::new();
for package in packages
.values()
.filter(|package| reachable.contains(&package.id) && package.source.is_some())
{
let directory = package
.manifest_path
.parent()
.ok_or("dependency manifest has no parent directory")?;
let mut license_paths = Vec::new();
if let Some(path) = &package.license_file {
license_paths.push(directory.join(path));
} else {
for entry in fs::read_dir(directory)? {
let path = entry?.path();
let name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
if path.is_file()
&& ["LICENSE", "LICENCE", "COPYING", "NOTICE"]
.iter()
.any(|prefix| name.to_ascii_uppercase().starts_with(prefix))
{
license_paths.push(path);
}
}
}
license_paths.sort();
license_paths.dedup();
package_notices.push_str(&format!(
"\n{} {}\nLicense: {}\nSource: {}\n",
package.name,
package.version,
package.license.as_deref().unwrap_or("see included license"),
package.repository.as_deref().unwrap_or("see Cargo.lock")
));
if license_paths.is_empty() {
package_notices.push_str(
"Notice: the published crate contains SPDX metadata but no separate license file.\n",
);
}
for path in license_paths {
let text = fs::read_to_string(&path)?
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n");
license_texts.entry(text).or_default().push(format!(
"{} {} ({})",
package.name,
package.version,
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("license")
));
}
}
let mut notices = format!(
"IronStorage third-party licenses\n================================\n\nGenerated from Cargo metadata for the iPhone Rust library. Source links also satisfy source-availability notice requirements for covered dependencies.\n\nPackages\n--------\n{package_notices}\nLicense texts\n-------------\n"
);
for (text, used_by) in license_texts {
notices.push_str(&format!(
"\nUsed by: {}\n\n{}\n",
used_by.join(", "),
text.trim()
));
}
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(output)?;
file.write_all(notices.as_bytes())?;
Ok(())
}
fn require_https(name: &str, value: &str) -> Result<(), Box<dyn Error>> {
let url = Url::parse(value)?;
if url.scheme() != "https"
|| url.host_str().is_none()
|| url.username() != ""
|| url.password().is_some()
{
return Err(format!("{name} must be an HTTPS URL without credentials").into());
}
Ok(())
}
fn png_size(path: &Path) -> Result<(u32, u32), Box<dyn Error>> {
let bytes = fs::read(path)?;
if bytes.len() < 24 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" || &bytes[12..16] != b"IHDR" {
return Err(format!("{} is not a PNG with an IHDR", path.display()).into());
}
Ok((
u32::from_be_bytes(bytes[16..20].try_into()?),
u32::from_be_bytes(bytes[20..24].try_into()?),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn metadata() -> Metadata {
toml::from_str(include_str!("../../../apple/AppStore/metadata.toml")).unwrap()
}
#[test]
fn metadata_keeps_the_watch_companion_in_release_scope() {
let metadata = metadata();
assert_eq!(metadata.app.bundle_identifier, BUNDLE_ID);
assert!(metadata.release.notes.contains("Apple Watch"));
}
#[test]
fn repository_release_metadata_is_consistent() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap();
verify(root).unwrap();
}
}

View File

@@ -4,6 +4,8 @@ description = "Build IronStorage macOS release bundles and DMGs"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false
[dependencies]