85d0eaddbb
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.
113 lines
5.3 KiB
Swift
Executable File
113 lines
5.3 KiB
Swift
Executable File
#!/usr/bin/env swift
|
|
//
|
|
// asc-set-compliance.swift — declare export compliance for builds that are
|
|
// missing it (usesNonExemptEncryption = NO) via the App Store Connect API.
|
|
//
|
|
// Usage: source .env.release && swift Scripts/asc-set-compliance.swift [bundleId]
|
|
//
|
|
// Only needed for builds uploaded WITHOUT ITSAppUsesNonExemptEncryption in
|
|
// Info.plist; builds that carry that key declare compliance automatically.
|
|
// 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: "")
|
|
}
|
|
|
|
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)")
|
|
}
|
|
let bundleID = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "dev.rzen.indie.Workouts"
|
|
|
|
// ---- short-lived ES256 JWT --------------------------------------------------
|
|
guard let pem = try? String(contentsOfFile: keyPath, encoding: .utf8) else { die("cannot read key at \(keyPath)") }
|
|
guard let key = try? P256.Signing.PrivateKey(pemRepresentation: pem) else { die("could not parse EC key at \(keyPath)") }
|
|
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? key.signature(for: Data(signingInput.utf8)) else { die("signing failed") }
|
|
let jwt = signingInput + "." + b64url(sig.rawRepresentation)
|
|
|
|
// ---- API plumbing -----------------------------------------------------------
|
|
let api = "https://api.appstoreconnect.apple.com/v1"
|
|
|
|
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 \(jwt)", 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) ?? "" }
|
|
|
|
// ---- find the app -----------------------------------------------------------
|
|
let (aStatus, aData) = request("GET", "\(api)/apps?filter%5BbundleId%5D=\(bundleID)&fields%5Bapps%5D=bundleId,name")
|
|
guard aStatus == 200,
|
|
let aArr = parse(aData)?["data"] as? [[String: Any]],
|
|
let app = aArr.first,
|
|
let appID = app["id"] as? String else {
|
|
die("app not found for bundle \(bundleID) (status \(aStatus)): \(bodyText(aData))")
|
|
}
|
|
print("App: \(bundleID) (\(appID))")
|
|
|
|
// ---- list builds and declare any missing compliance -------------------------
|
|
let (bStatus, bData) = request("GET", "\(api)/builds?filter%5Bapp%5D=\(appID)&fields%5Bbuilds%5D=version,usesNonExemptEncryption,processingState,uploadedDate&limit=200")
|
|
guard bStatus == 200, let builds = parse(bData)?["data"] as? [[String: Any]] else {
|
|
die("could not list builds (status \(bStatus)): \(bodyText(bData))")
|
|
}
|
|
if builds.isEmpty { print("No builds found yet (still processing?)."); exit(0) }
|
|
|
|
var patched = 0, already = 0, failed = 0
|
|
for build in builds {
|
|
guard let id = build["id"] as? String, let attrs = build["attributes"] as? [String: Any] else { continue }
|
|
let version = attrs["version"] as? String ?? "?"
|
|
let state = attrs["processingState"] as? String ?? "?"
|
|
let needs = (attrs["usesNonExemptEncryption"] ?? NSNull()) is NSNull
|
|
let uploaded = attrs["uploadedDate"] as? String ?? "?"
|
|
let encVal = attrs["usesNonExemptEncryption"] ?? NSNull()
|
|
if !needs {
|
|
print("• build \(version) [\(state)] uploaded \(uploaded): already declared (usesNonExemptEncryption=\(encVal))")
|
|
already += 1
|
|
continue
|
|
}
|
|
let body = "{\"data\":{\"type\":\"builds\",\"id\":\"\(id)\",\"attributes\":{\"usesNonExemptEncryption\":false}}}".data(using: .utf8)!
|
|
let (pStatus, pData) = request("PATCH", "\(api)/builds/\(id)", body: body)
|
|
if pStatus == 200 {
|
|
print("✅ build \(version) [\(state)]: export compliance set (uses non-exempt encryption = NO)")
|
|
patched += 1
|
|
} else {
|
|
print("❌ build \(version) [\(state)]: PATCH failed (status \(pStatus)): \(bodyText(pData))")
|
|
failed += 1
|
|
}
|
|
}
|
|
print("Done. \(patched) updated, \(already) already set, \(failed) failed.")
|
|
if failed > 0 { exit(1) }
|