Prepare Apple App Store distribution (#59)
This commit is contained in:
15
tools/apple-release/Cargo.toml
Normal file
15
tools/apple-release/Cargo.toml
Normal 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
|
||||
395
tools/apple-release/src/main.rs
Normal file
395
tools/apple-release/src/main.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user