Files
workouts/Scripts/asc-metadata.swift
rzen 85d0eaddbb Workouts 2.0: re-base persistence on iCloud Drive documents
Replace Core Data + NSPersistentCloudKitContainer + App-Group store +
WatchConnectivity dictionary sync with the QuickRabbit iCloud-documents
architecture:

- iCloud Drive JSON documents are the sole source of truth (one file per
  aggregate: Splits/<ULID>.json, Workouts/YYYY/MM/<ULID>.json), with a
  rebuildable SwiftData cache populated only by an NSMetadataQuery observer
  and a connect-time reconcile. Soft-delete tombstones; hard iCloud gate.
- Shared model layer (ULID, Codable *Documents + stateless mappers, @Model
  cache entities, SwiftData container) compiled into both targets.
- New iPhone<->Watch bridge over WatchConnectivity keyed by ULIDs; the phone
  is the sole writer of iCloud Drive, the watch round-trips documents.
- AppServices DI + iCloud-required root gate; Swift 6 strict concurrency.
- Starter splits generated on demand from the bundled YAML catalogs.
- Migrate to XcodeGen (project.yml), iOS 26 / watchOS 26; CloudDocuments
  entitlement (drop CloudKit/App Group/aps-environment).
- Duration stored as Int seconds (was a Date epoch hack); fix workout
  end-on-create, undismissable delete dialog, toolbar-hiding nav stacks,
  and the Settings placeholder.
- Add README/CHANGELOG/LICENSE, .gitignore, refreshed REQUIREMENTS, and the
  Scripts/ TestFlight pipeline (release.sh + ASC API scripts).

MARKETING_VERSION 2.0.
2026-06-19 14:25:27 -04:00

390 lines
20 KiB
Swift
Executable File

#!/usr/bin/env swift
//
// asc-metadata.swift push App Store listing metadata from local files to
// App Store Connect via the API. Run before Scripts/asc-submit.swift.
//
// Usage: source .env.release && swift Scripts/asc-metadata.swift [options]
//
// Options:
// --version <X> Marketing version to write (default: CFBundleShort-
// VersionString from Workouts/Resources/Info-iOS.plist). Created if absent.
// --bundle <id> Bundle identifier (default: dev.rzen.indie.Workouts).
// --dir <path> Metadata folder (default: Scripts/metadata).
// --dry-run Print what would change; make no writes.
//
// Folder layout (everything is optional only what's present is pushed):
//
// metadata/
// app/
// categories.json {"primary":"HEALTH_AND_FITNESS","secondary":null}
// <locale>/ e.g. en-US
// name.txt app name (30) -> appInfoLocalizations
// subtitle.txt subtitle (30)
// privacy_url.txt privacy policy URL
// version/
// review.json {"contactFirstName":..,"contactEmail":..,"notes":..}
// <locale>/
// description.txt description (4000) -> appStoreVersionLocalizations
// keywords.txt comma-separated (100)
// promotional_text.txt promo text (170)
// whats_new.txt release notes (4000) (omit for first version)
// marketing_url.txt
// support_url.txt
// screenshots/
// <locale>/
// <DISPLAY_TYPE>/ e.g. APP_IPHONE_67, APP_WATCH_ULTRA
// 01_first.png 02_second.png (sorted by name; the local set is
// authoritative existing remote
// shots in that set are replaced)
//
// What it pushes: app name / subtitle / privacy URL / categories, version
// description / keywords / promo / what's-new / URLs, screenshots, and the
// review contact details. NOT here (separate resource families): age rating,
// pricing/availability, and App Privacy labels.
//
// Reads ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH from the environment.
//
import Foundation
import CryptoKit
func die(_ msg: String) -> Never {
FileHandle.standardError.write((msg + "\n").data(using: .utf8)!)
exit(1)
}
func b64url(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
// ---- arguments --------------------------------------------------------------
var bundleID = "dev.rzen.indie.Workouts"
var versionArg: String?
var metaDir = "Scripts/metadata"
var dryRun = false
do {
let args = Array(CommandLine.arguments.dropFirst())
var i = 0
func value(_ flag: String) -> String {
i += 1
guard i < args.count else { die("\(flag) needs a value") }
return args[i]
}
while i < args.count {
switch args[i] {
case "--bundle": bundleID = value("--bundle")
case "--version": versionArg = value("--version")
case "--dir": metaDir = value("--dir")
case "--dry-run": dryRun = true
case "--help", "-h":
print("usage: swift Scripts/asc-metadata.swift [--version X] [--bundle id] [--dir path] [--dry-run]")
exit(0)
default: die("unknown argument: \(args[i])")
}
i += 1
}
}
let versionString: String = versionArg ?? {
let path = "Workouts/Resources/Info-iOS.plist"
guard let data = FileManager.default.contents(atPath: path),
let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any],
let v = plist["CFBundleShortVersionString"] as? String else {
die("could not read CFBundleShortVersionString from \(path); pass --version")
}
return v
}()
// ---- ES256 JWT (fresh per request) ------------------------------------------
let env = ProcessInfo.processInfo.environment
guard let keyID = env["ASC_KEY_ID"], let issuer = env["ASC_ISSUER_ID"], let keyPath = env["ASC_KEY_PATH"] else {
die("missing ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH (source .env.release first)")
}
guard let pem = try? String(contentsOfFile: keyPath, encoding: .utf8) else { die("cannot read key at \(keyPath)") }
guard let signingKey = try? P256.Signing.PrivateKey(pemRepresentation: pem) else { die("could not parse EC key at \(keyPath)") }
func token() -> String {
let now = Int(Date().timeIntervalSince1970)
let header = "{\"alg\":\"ES256\",\"kid\":\"\(keyID)\",\"typ\":\"JWT\"}"
let payload = "{\"iss\":\"\(issuer)\",\"iat\":\(now),\"exp\":\(now + 1200),\"aud\":\"appstoreconnect-v1\"}"
let signingInput = b64url(Data(header.utf8)) + "." + b64url(Data(payload.utf8))
guard let sig = try? signingKey.signature(for: Data(signingInput.utf8)) else { die("signing failed") }
return signingInput + "." + b64url(sig.rawRepresentation)
}
// ---- API plumbing -----------------------------------------------------------
let api = "https://api.appstoreconnect.apple.com/v1"
@discardableResult
func request(_ method: String, _ urlStr: String, body: Data? = nil) -> (Int, Data) {
guard let url = URL(string: urlStr) else { die("bad url: \(urlStr)") }
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("Bearer \(token())", forHTTPHeaderField: "Authorization")
if let body {
req.httpBody = body
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
let sem = DispatchSemaphore(value: 0)
var status = 0
var data = Data()
URLSession.shared.dataTask(with: req) { d, resp, err in
if let err { die("network error: \(err)") }
status = (resp as? HTTPURLResponse)?.statusCode ?? 0
data = d ?? Data()
sem.signal()
}.resume()
sem.wait()
return (status, data)
}
func parse(_ data: Data) -> [String: Any]? { try? JSONSerialization.jsonObject(with: data) as? [String: Any] }
func bodyText(_ data: Data) -> String { String(data: data, encoding: .utf8) ?? "" }
func dataArray(_ data: Data) -> [[String: Any]] { (parse(data)?["data"] as? [[String: Any]]) ?? [] }
func dataObject(_ data: Data) -> [String: Any]? { parse(data)?["data"] as? [String: Any] }
func attrs(_ obj: [String: Any]) -> [String: Any] { (obj["attributes"] as? [String: Any]) ?? [:] }
func intval(_ any: Any?) -> Int { (any as? Int) ?? (any as? NSNumber)?.intValue ?? 0 }
/// Build a JSON:API request body. Text goes through JSONSerialization, so
/// quotes / newlines in a description are escaped correctly.
func apiBody(type: String, id: String? = nil, attributes: [String: Any]? = nil, relationships: [String: Any]? = nil) -> Data {
var data: [String: Any] = ["type": type]
if let id { data["id"] = id }
if let attributes, !attributes.isEmpty { data["attributes"] = attributes }
if let relationships, !relationships.isEmpty { data["relationships"] = relationships }
return (try? JSONSerialization.data(withJSONObject: ["data": data])) ?? Data()
}
func toOne(_ type: String, _ id: String) -> [String: Any] { ["data": ["type": type, "id": id]] }
// ---- local files ------------------------------------------------------------
let fm = FileManager.default
func fileText(_ path: String) -> String? {
guard let data = fm.contents(atPath: path), let s = String(data: data, encoding: .utf8) else { return nil }
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
func subdirs(_ path: String) -> [String] {
((try? fm.contentsOfDirectory(atPath: path)) ?? [])
.filter { var d: ObjCBool = false; return fm.fileExists(atPath: "\(path)/\($0)", isDirectory: &d) && d.boolValue }
.sorted()
}
func files(_ path: String) -> [String] {
((try? fm.contentsOfDirectory(atPath: path)) ?? [])
.filter { !$0.hasPrefix(".") }
.filter { var d: ObjCBool = false; return fm.fileExists(atPath: "\(path)/\($0)", isDirectory: &d) && !d.boolValue }
.sorted()
}
func md5hex(_ data: Data) -> String { Insecure.MD5.hash(data: data).map { String(format: "%02x", $0) }.joined() }
/// Collect non-nil attributes from (file -> attribute) pairs in a locale dir.
func attributesFrom(_ dir: String, _ map: [(file: String, attr: String)]) -> [String: Any] {
var out: [String: Any] = [:]
for m in map { if let v = fileText("\(dir)/\(m.file)") { out[m.attr] = v } }
return out
}
func patchOrCreate(_ label: String, type: String, existingID: String?,
attributes: [String: Any], relationships: [String: Any]) {
if attributes.isEmpty { return }
if dryRun {
print(" ~ \(label): \(attributes.keys.sorted().joined(separator: ", ")) \(existingID == nil ? "(create)" : "(update)")")
return
}
let (status, data): (Int, Data)
if let id = existingID {
(status, data) = request("PATCH", "\(api)/\(type)/\(id)", body: apiBody(type: type, id: id, attributes: attributes))
} else {
(status, data) = request("POST", "\(api)/\(type)", body: apiBody(type: type, attributes: attributes, relationships: relationships))
}
guard status == 200 || status == 201 else { die("\(label): write failed (status \(status)): \(bodyText(data))") }
print("\(label): \(attributes.keys.sorted().joined(separator: ", "))")
}
// ---- 1. find the app --------------------------------------------------------
let (appStatus, appData) = request("GET", "\(api)/apps?filter%5BbundleId%5D=\(bundleID)&fields%5Bapps%5D=bundleId,name")
guard appStatus == 200, let app = dataArray(appData).first, let appID = app["id"] as? String else {
die("app not found for bundle \(bundleID) (status \(appStatus)): \(bodyText(appData))")
}
print("App: \(bundleID) (\(appID)) • version \(versionString)\(dryRun ? " [DRY RUN]" : "")")
// ---- 2. resolve the editable appInfo (app-level metadata lives here) --------
let liveStates: Set<String> = ["READY_FOR_SALE", "REMOVED_FROM_SALE", "DEVELOPER_REMOVED_FROM_SALE", "REPLACED_WITH_NEW_INFO", "NOT_APPLICABLE"]
let (aiStatus, aiData) = request("GET", "\(api)/apps/\(appID)/appInfos")
guard aiStatus == 200 else { die("could not list appInfos (status \(aiStatus)): \(bodyText(aiData))") }
let appInfos = dataArray(aiData)
func appInfoState(_ o: [String: Any]) -> String { (attrs(o)["state"] ?? attrs(o)["appStoreState"]) as? String ?? "" }
guard let appInfo = appInfos.first(where: { !liveStates.contains(appInfoState($0)) }) ?? appInfos.first,
let appInfoID = appInfo["id"] as? String else {
die("no editable appInfo found")
}
// ---- 3. find or create the editable App Store version -----------------------
let (vStatus, vData) = request("GET", "\(api)/apps/\(appID)/appStoreVersions?filter%5Bplatform%5D=IOS&filter%5BversionString%5D=\(versionString)&fields%5BappStoreVersions%5D=versionString&limit=1")
guard vStatus == 200 else { die("could not query versions (status \(vStatus)): \(bodyText(vData))") }
var versionID = dataArray(vData).first?["id"] as? String
if versionID == nil {
if dryRun {
print("App Store version \(versionString): would create")
versionID = "<new>"
} else {
let (s, d) = request("POST", "\(api)/appStoreVersions",
body: apiBody(type: "appStoreVersions",
attributes: ["platform": "IOS", "versionString": versionString],
relationships: ["app": toOne("apps", appID)]))
guard s == 201 || s == 200, let id = dataObject(d)?["id"] as? String else {
die("could not create App Store version \(versionString) (status \(s)): \(bodyText(d))")
}
versionID = id
print("🆕 created App Store version \(versionString) (\(id))")
}
}
let vID = versionID!
// ---- 4. categories (app-level) ----------------------------------------------
if let cData = fm.contents(atPath: "\(metaDir)/app/categories.json"),
let cats = try? JSONSerialization.jsonObject(with: cData) as? [String: Any] {
var rel: [String: Any] = [:]
if let p = cats["primary"] as? String { rel["primaryCategory"] = toOne("appCategories", p) }
if let s = cats["secondary"] as? String { rel["secondaryCategory"] = toOne("appCategories", s) }
if !rel.isEmpty {
if dryRun {
print("Categories: \(rel.keys.sorted().joined(separator: ", ")) (update)")
} else {
let (s, d) = request("PATCH", "\(api)/appInfos/\(appInfoID)", body: apiBody(type: "appInfos", id: appInfoID, relationships: rel))
guard s == 200 else { die("category update failed (status \(s)): \(bodyText(d))") }
print("✅ categories set")
}
}
}
// ---- 5. app-level localizations (name, subtitle, privacy URL) ---------------
let appLocRoot = "\(metaDir)/app"
let (ailStatus, ailData) = request("GET", "\(api)/appInfos/\(appInfoID)/appInfoLocalizations?limit=200")
let existingAppLocs = ailStatus == 200 ? dataArray(ailData) : []
for locale in subdirs(appLocRoot) where locale != "categories.json" {
let dir = "\(appLocRoot)/\(locale)"
let attributes = attributesFrom(dir, [("name.txt", "name"), ("subtitle.txt", "subtitle"), ("privacy_url.txt", "privacyPolicyUrl")])
guard !attributes.isEmpty else { continue }
print("App info • \(locale)")
let existing = existingAppLocs.first { attrs($0)["locale"] as? String == locale }?["id"] as? String
var createAttrs = attributes
if existing == nil { createAttrs["locale"] = locale }
patchOrCreate("appInfoLocalizations[\(locale)]", type: "appInfoLocalizations", existingID: existing,
attributes: createAttrs, relationships: ["appInfo": toOne("appInfos", appInfoID)])
}
// ---- 6. version localizations (description, keywords, urls, promo, whats-new)
let verLocRoot = "\(metaDir)/version"
let (avlStatus, avlData) = request("GET", "\(api)/appStoreVersions/\(vID)/appStoreVersionLocalizations?limit=200")
let existingVerLocs = avlStatus == 200 ? dataArray(avlData) : []
for locale in subdirs(verLocRoot) {
let dir = "\(verLocRoot)/\(locale)"
let attributes = attributesFrom(dir, [
("description.txt", "description"),
("keywords.txt", "keywords"),
("promotional_text.txt", "promotionalText"),
("whats_new.txt", "whatsNew"),
("marketing_url.txt", "marketingUrl"),
("support_url.txt", "supportUrl"),
])
guard !attributes.isEmpty else { continue }
print("Version info • \(locale)")
let existing = existingVerLocs.first { attrs($0)["locale"] as? String == locale }?["id"] as? String
var createAttrs = attributes
if existing == nil { createAttrs["locale"] = locale }
patchOrCreate("appStoreVersionLocalizations[\(locale)]", type: "appStoreVersionLocalizations", existingID: existing,
attributes: createAttrs, relationships: ["appStoreVersion": toOne("appStoreVersions", vID)])
}
// ---- 7. review details ------------------------------------------------------
if let rData = fm.contents(atPath: "\(verLocRoot)/review.json"),
let review = try? JSONSerialization.jsonObject(with: rData) as? [String: Any], !review.isEmpty {
print("Review details")
let (rdStatus, rdData) = request("GET", "\(api)/appStoreVersions/\(vID)/appStoreReviewDetail")
let existing = rdStatus == 200 ? dataObject(rdData)?["id"] as? String : nil
patchOrCreate("appStoreReviewDetails", type: "appStoreReviewDetails", existingID: existing,
attributes: review, relationships: ["appStoreVersion": toOne("appStoreVersions", vID)])
}
// ---- 8. screenshots ---------------------------------------------------------
// The local folder is authoritative: for every display type present locally,
// the matching remote set is cleared and re-uploaded in filename order.
func uploadScreenshot(setID: String, fileURL: String) {
guard let data = fm.contents(atPath: fileURL) else { die("cannot read \(fileURL)") }
let name = (fileURL as NSString).lastPathComponent
// reserve
let (rs, rd) = request("POST", "\(api)/appScreenshots",
body: apiBody(type: "appScreenshots",
attributes: ["fileSize": data.count, "fileName": name],
relationships: ["appScreenshotSet": toOne("appScreenshotSets", setID)]))
guard rs == 201 || rs == 200, let obj = dataObject(rd), let shotID = obj["id"] as? String,
let ops = attrs(obj)["uploadOperations"] as? [[String: Any]] else {
die("reserve failed for \(name) (status \(rs)): \(bodyText(rd))")
}
// upload each chunk to Apple's pre-signed URLs (no bearer token use the
// headers Apple hands back)
for op in ops {
guard let urlStr = op["url"] as? String, let url = URL(string: urlStr) else { die("bad upload op for \(name)") }
let offset = intval(op["offset"]); let length = intval(op["length"])
let chunk = data.subdata(in: offset ..< (offset + length))
var req = URLRequest(url: url)
req.httpMethod = (op["method"] as? String) ?? "PUT"
for h in (op["requestHeaders"] as? [[String: Any]]) ?? [] {
if let n = h["name"] as? String, let v = h["value"] as? String { req.setValue(v, forHTTPHeaderField: n) }
}
req.httpBody = chunk
let sem = DispatchSemaphore(value: 0); var st = 0
URLSession.shared.dataTask(with: req) { _, resp, err in
if let err { die("upload error for \(name): \(err)") }
st = (resp as? HTTPURLResponse)?.statusCode ?? 0; sem.signal()
}.resume()
sem.wait()
guard (200...299).contains(st) else { die("chunk upload failed for \(name) (status \(st))") }
}
// commit
let (cs, cd) = request("PATCH", "\(api)/appScreenshots/\(shotID)",
body: apiBody(type: "appScreenshots", id: shotID,
attributes: ["uploaded": true, "sourceFileChecksum": md5hex(data)]))
guard cs == 200 else { die("commit failed for \(name) (status \(cs)): \(bodyText(cd))") }
print(" ⬆︎ \(name)")
}
let shotRoot = "\(metaDir)/screenshots"
for locale in subdirs(shotRoot) {
// existing screenshot sets for this locale's version localization
let locID = existingVerLocs.first { attrs($0)["locale"] as? String == locale }?["id"] as? String
for displayType in subdirs("\(shotRoot)/\(locale)") {
let imgDir = "\(shotRoot)/\(locale)/\(displayType)"
let images = files(imgDir)
guard !images.isEmpty else { continue }
print("Screenshots • \(locale)\(displayType) (\(images.count))")
if dryRun { images.forEach { print(" ⬆︎ \($0) (dry-run)") }; continue }
guard let locID else { die("no version localization for \(locale); add version/\(locale)/ text first") }
// find or create the set
let (ssStatus, ssData) = request("GET", "\(api)/appStoreVersionLocalizations/\(locID)/appScreenshotSets?limit=200")
var setID = (ssStatus == 200 ? dataArray(ssData) : []).first { attrs($0)["screenshotDisplayType"] as? String == displayType }?["id"] as? String
if setID == nil {
let (s, d) = request("POST", "\(api)/appScreenshotSets",
body: apiBody(type: "appScreenshotSets",
attributes: ["screenshotDisplayType": displayType],
relationships: ["appStoreVersionLocalization": toOne("appStoreVersionLocalizations", locID)]))
guard s == 201 || s == 200, let id = dataObject(d)?["id"] as? String else {
die("could not create screenshot set \(displayType) (status \(s)): \(bodyText(d))")
}
setID = id
} else {
// clear existing shots so the local set is authoritative
let (es, ed) = request("GET", "\(api)/appScreenshotSets/\(setID!)/appScreenshots?limit=200")
for shot in (es == 200 ? dataArray(ed) : []) {
if let id = shot["id"] as? String { request("DELETE", "\(api)/appScreenshots/\(id)") }
}
}
for img in images { uploadScreenshot(setID: setID!, fileURL: "\(imgDir)/\(img)") }
}
}
print("")
print(dryRun ? "✅ Dry run complete — no changes written." : "✅ Metadata pushed. Next: swift Scripts/asc-submit.swift")