Skip to content

Commit e8f20d8

Browse files
committed
Add ability to upload releaes without forking gh
1 parent cfbae19 commit e8f20d8

7 files changed

Lines changed: 391 additions & 360 deletions

File tree

Sources/FairExpo/ADPManifest.swift

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@ import Foundation
22

33
/// https://developer.apple.com/documentation/marketplacekit/ingesting-an-alternative-distribution-package#Process-the-manifest-file
44
public struct ADPManifest: Codable, Equatable {
5-
public var distributionPackageRevision: Int // 1
6-
public var appleItemId: String // "10738181399"
7-
public var bundleId: String // "com.megabytemart.backyardbirds"
8-
public var shortVersionString: String // "1.5"
9-
public var bundleVersion: String // "1"
10-
public var appleVersionId: String // "2000013060"
5+
public var manifestSchemaVersion: String? // "1.0"
6+
public var distributionPackageRevision: Int // 1, 2 for subsequent manifest delivery with deltas…
7+
public var appleItemId: String // "6740916318"
8+
public var bundleId: String // "org.appfair.app.SkipNotes"
9+
public var shortVersionString: String // "0.8.7"
10+
public var bundleVersion: String // "44"
11+
public var appleVersionId: String // "879184094"
1112
public var platforms: [String] // [ "ios" ]
1213
public var minimumSystemVersions: [String: String] // { "ios": "17.2" }
1314
public var requiredDeviceCapabilities: [String] // ["arm64"]
1415
//public var appInstallDeterminants: [Any] // unsure what this data type should be
16+
public var hasMessagesExtension: Bool?
17+
public var isLaunchProhibited: Bool?
1518
public var variants: [Variant]
1619
public var deltas: [Delta]
1720

@@ -31,8 +34,8 @@ public struct ADPManifest: Codable, Equatable {
3134

3235
public struct SourceVariant: Codable, Equatable {
3336
public var installTargets: [[String: String]] // [ { "device": "iPhone14,5", "os": "17.4" }, … ]
34-
public var appleVersionId: String // "2000011760"
35-
public var version: String // "1.1"
37+
public var appleVersionId: String // "878080263"
38+
public var version: String // "0.8.5"
3639
}
3740
}
3841

Sources/FairExpo/AppStoreConnectAPI.swift

Lines changed: 196 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,48 @@ public struct AppStoreConnectEndpoint : EndpointService {
264264
}
265265
}
266266

267+
public struct ADPVariantRequest : APIRequest {
268+
public typealias Response = DownloadResponse
269+
let variantID: UUID
270+
271+
public init(variantID: UUID) {
272+
self.variantID = variantID
273+
}
274+
275+
public func queryURL(for service: AppStoreConnectEndpoint) -> URL {
276+
service.endpointBase.appending(components: "alternativeDistributionPackageVariants", variantID.uuidString.lowercased())
277+
}
278+
}
279+
280+
public struct ADPDeltaRequest : APIRequest {
281+
public typealias Response = DownloadResponse
282+
let deltaID: UUID
283+
284+
public init(deltaID: UUID) {
285+
self.deltaID = deltaID
286+
}
287+
288+
public func queryURL(for service: AppStoreConnectEndpoint) -> URL {
289+
service.endpointBase.appending(components: "alternativeDistributionPackageDeltas", deltaID.uuidString.lowercased())
290+
}
291+
}
292+
293+
/// A response for either the variants or deltas
294+
public struct DownloadResponse : Codable {
295+
public let data: DownloadData?
296+
297+
public struct DownloadData : Codable {
298+
public let type: String // "alternativeDistributionPackageVariants" or "alternativeDistributionPackageDeltas"
299+
public let id: UUID // "219750db-80c2-4c75-aecc-fa67835f384d"
300+
public let attributes: Attributes
301+
public struct Attributes : Codable {
302+
public let url: URL // "<Apple_CDN_base_URL>/mzpse.7668245576990498917.ipa?accessKey=<access_key>"
303+
public let urlExpirationDate: Date
304+
public let alternativeDistributionKeyBlob: String // "<key_blob_base64_encoded_string>"
305+
}
306+
}
307+
}
308+
267309
public struct Relationship : Codable {
268310
public var links: Links
269311
}
@@ -284,6 +326,160 @@ public struct AppStoreConnectEndpoint : EndpointService {
284326
}
285327
}
286328

329+
extension AppStoreConnectEndpoint {
330+
public struct ADPDownloadError : LocalizedError {
331+
public var errorDescription: String?
332+
}
333+
334+
/// Look up the ADP ID for the given app version ID
335+
public func fetchADPId(forVersionID versionID: String) async throws -> String {
336+
// version id was specified; fetch the version endpoint and get the adpid from it
337+
let versions = try await self.request(AlternativeDistributionPackage(id: versionID))
338+
339+
guard let versionsData = versions.data else {
340+
throw ADPDownloadError(errorDescription: "Response did not contain any data payload")
341+
}
342+
343+
if versionsData.type != "alternativeDistributionPackages" {
344+
throw ADPDownloadError(errorDescription: "Response did not contain an alternative distribution package")
345+
}
346+
347+
return versionsData.id
348+
}
349+
350+
/// Given either an ADP ID or a version ID, return either the ADP ID itself or else resolve the version ID's ADP ID and return that
351+
public func resolveADPID(from adpID: String?, versionID: String?) async throws -> String {
352+
if let adpID {
353+
// we specified the ADP ID directly, so just use that
354+
return adpID
355+
}
356+
guard let versionID else {
357+
throw ADPDownloadError(errorDescription: "Either --appid or --versionid must be specified")
358+
}
359+
360+
let adpID = try await self.fetchADPId(forVersionID: versionID)
361+
return adpID
362+
}
363+
364+
/// Downloads all the assets from an Alternative Distribution Package to the speficied directory
365+
/// - Parameters:
366+
/// - adpid: either the ADP ID or the version ID must be specified
367+
/// - versionid: either the ADP ID or the version ID must be specified
368+
/// - directory: the base directory to download the package
369+
/// - logger: an optional logger for an Encodable
370+
/// - Returns: a map from relative file paths to absolute locations
371+
public func downloadADP(adpid: String, directory: String?, logger: ((String?) -> ())?) async throws -> (ADPManifest, [String: URL]) {
372+
373+
var downloaded: [String: URL] = [:]
374+
375+
let adpVersions = try await self.request(AlternativeDistributionVersions(adpID: adpid))
376+
377+
// TODO: wait for completed?
378+
guard let adpVersionsData = adpVersions.data else {
379+
throw ADPDownloadError(errorDescription: "No data in response")
380+
}
381+
382+
guard let completedADPVersion = adpVersionsData.first(where: { $0.attributes.state == "COMPLETED" }) else {
383+
throw ADPDownloadError(errorDescription: "No completed version found for this ADP: \(adpVersionsData.map(\.attributes.state).joined(separator: ", "))")
384+
}
385+
386+
guard let manifestSignatureZipURL = completedADPVersion.attributes.url else {
387+
throw ADPDownloadError(errorDescription: "No download URL found for ADP")
388+
}
389+
390+
// download the URL and unzip manifest.json and signature to the directory
391+
let (downloadFile, response) = try await URLSession.shared.downloadFile(for: URLRequest(url: manifestSignatureZipURL, cachePolicy: .returnCacheDataElseLoad))
392+
try response.validateHTTPCode()
393+
defer { try? FileManager.default.removeItem(at: downloadFile) }
394+
395+
//let adpReleaseID = completedADPVersion.id
396+
let expandPath = URL(fileURLWithPath: directory ?? FileManager.default.currentDirectoryPath).appendingPathComponent(adpid, isDirectory: true)
397+
398+
if expandPath.pathIsDirectory {
399+
try FileManager.default.trash(url: expandPath)
400+
}
401+
try FileManager.default.unzipItem(at: downloadFile, to: expandPath)
402+
403+
let signaturePath = expandPath.appendingPathComponent("signature", isDirectory: false)
404+
if !signaturePath.pathIsRegularFile {
405+
throw ADPDownloadError(errorDescription: "signature file not found in ADP package at \(expandPath.path)")
406+
}
407+
downloaded[signaturePath.lastPathComponent] = signaturePath
408+
409+
let manifestPath = expandPath.appendingPathComponent("manifest.json", isDirectory: false)
410+
if !manifestPath.pathIsRegularFile {
411+
throw ADPDownloadError(errorDescription: "manifest.json file not found in ADP package at \(expandPath.path)")
412+
}
413+
downloaded[manifestPath.lastPathComponent] = manifestPath
414+
415+
let manifest = try JSONDecoder().decode(ADPManifest.self, from: Data(contentsOf: manifestPath))
416+
//try logger?(manifest)
417+
418+
func downloadAsset(from sourceURL: URL, to destinationPath: String, checksum: String?) async throws {
419+
// TODO: retry options
420+
let (downloadDeltaFile, response) = try await URLSession.shared.downloadFile(for: URLRequest(url: sourceURL, cachePolicy: .returnCacheDataElseLoad))
421+
try response.validateHTTPCode()
422+
let destination = expandPath.appending(path: destinationPath)
423+
try FileManager.default.moveItem(at: downloadDeltaFile, to: destination)
424+
if let checksum {
425+
// validate the checksum if it is specified
426+
let fileChecksum = try Data(contentsOf: destination, options: .mappedIfSafe).sha256().hex()
427+
if checksum.lowercased() != fileChecksum.lowercased() {
428+
throw ADPDownloadError(errorDescription: "Checksum of downloaded file \(fileChecksum) does not match expected value \(checksum) at: \(destination.path)")
429+
}
430+
}
431+
downloaded[destinationPath] = destination
432+
}
433+
434+
// Store the app data at an expected path
435+
// https://developer.apple.com/documentation/marketplacekit/ingesting-an-alternative-distribution-package#Store-the-app-data-at-an-expected-path
436+
437+
// download each of the variants
438+
// https://developer.apple.com/documentation/marketplacekit/ingesting-an-alternative-distribution-package#Download-app-variants
439+
// GET https://api.appstoreconnect.apple.com/alternativeDistributionPackageVariants/219750db-80c2-4c75-aecc-fa67835f384d
440+
441+
let variantsFolder = expandPath.appendingPathComponent("variant", isDirectory: true)
442+
try FileManager.default.createDirectory(at: variantsFolder, withIntermediateDirectories: false)
443+
guard let variantsRelationship = completedADPVersion.relationships["variants"] else {
444+
throw ADPDownloadError(errorDescription: "No variants found for ADP")
445+
}
446+
_ = variantsRelationship // TODO: cross-reference variants result with manifest variants to validate
447+
for variantInfo in manifest.variants {
448+
logger?("downloading variant: \(variantInfo.assetPath)")
449+
let variantResponse = try await self.request(ADPVariantRequest(variantID: variantInfo.publicId))
450+
451+
guard let variantResponseData = variantResponse.data else {
452+
throw ADPDownloadError(errorDescription: "No data returned from API")
453+
}
454+
let variantChecksums = variantInfo.variantDetails.hashes.first(where: { $0.algorithm == "sha256" })?.encryptedChunkDigests
455+
try await downloadAsset(from: variantResponseData.attributes.url, to: variantInfo.assetPath, checksum: variantChecksums?.count == 1 ? variantChecksums?.first : nil)
456+
}
457+
458+
459+
// download each of the deltas (optional; might not be included in the initial reported version)
460+
// “When App Store Connect sends a new app version notification, it sends an app distribution package that includes the variants first, followed by another for deltas, if any are available for the app. Deltas arrive an unspecified amount of time after the app’s variants. You don’t need to wait for deltas to arrive before serving the new app to devices. Rather, App Store Connect sends variants first to expedite the app’s availability for customers.”
461+
// https://developer.apple.com/documentation/marketplacekit/ingesting-an-alternative-distribution-package#Download-app-deltas
462+
463+
let deltasFolder = expandPath.appendingPathComponent("delta", isDirectory: true)
464+
try FileManager.default.createDirectory(at: deltasFolder, withIntermediateDirectories: false)
465+
guard let deltasRelationship = completedADPVersion.relationships["deltas"] else {
466+
throw ADPDownloadError(errorDescription: "No deltas found for ADP")
467+
}
468+
_ = deltasRelationship // TODO: cross-reference deltas result with manifest deltas to validate
469+
for deltaInfo in manifest.deltas {
470+
logger?("downloading delta: \(deltaInfo.assetPath)")
471+
let deltaResponse = try await self.request(ADPDeltaRequest(deltaID: deltaInfo.publicId))
472+
guard let deltaResponseData = deltaResponse.data else {
473+
throw ADPDownloadError(errorDescription: "No data in response")
474+
}
475+
let deltaChecksums = deltaInfo.deltaDetails.hashes.first(where: { $0.algorithm == "sha256" })?.encryptedChunkDigests
476+
try await downloadAsset(from: deltaResponseData.attributes.url, to: deltaInfo.assetPath, checksum: deltaChecksums?.count == 1 ? deltaChecksums?.first : nil)
477+
}
478+
479+
return (manifest, downloaded)
480+
}
481+
}
482+
287483
private extension Data {
288484
func base64URLEncodedString() -> String {
289485
return self.base64EncodedString()
@@ -292,87 +488,3 @@ private extension Data {
292488
.replacingOccurrences(of: "=", with: "")
293489
}
294490
}
295-
296-
297-
//// MARK: - Configuration
298-
//let issuerID = "<YOUR_ISSUER_ID>"
299-
//let keyID = "<YOUR_KEY_ID>"
300-
//let privateKeyPath = "apple_key.p8"
301-
//
302-
//// MARK: - JWT Generation
303-
//func loadPrivateKey(from path: String) -> P256.Signing.PrivateKey? {
304-
// guard let pemString = try? String(contentsOfFile: path),
305-
// let keyData = extractKeyData(from: pemString) else {
306-
// print("Failed to load or parse .p8 file")
307-
// return nil
308-
// }
309-
//
310-
// return try? P256.Signing.PrivateKey(rawRepresentation: keyData)
311-
//}
312-
//
313-
//func extractKeyData(from pem: String) -> Data? {
314-
// let lines = pem.components(separatedBy: .newlines)
315-
// let base64Key = lines
316-
// .filter { !$0.contains("BEGIN") && !$0.contains("END") }
317-
// .joined()
318-
// return Data(base64Encoded: base64Key)
319-
//}
320-
//
321-
//func generateJWT(using privateKey: P256.Signing.PrivateKey) -> String? {
322-
// let header = ["alg": "ES256", "kid": keyID]
323-
// let iat = Int(Date().timeIntervalSince1970)
324-
// let exp = iat + 1200 // 20 minutes
325-
// let payload: [String: Any] = ["iss": issuerID, "iat": iat, "exp": exp, "aud": "appstoreconnect-v1"]
326-
//
327-
// guard let headerData = try? JSONSerialization.data(withJSONObject: header),
328-
// let payloadData = try? JSONSerialization.data(withJSONObject: payload) else {
329-
// return nil
330-
// }
331-
//
332-
// let headerBase64 = headerData.base64URLEncodedString()
333-
// let payloadBase64 = payloadData.base64URLEncodedString()
334-
// let signingInput = "\(headerBase64).\(payloadBase64)"
335-
//
336-
// let signature = try? privateKey.signature(for: Data(signingInput.utf8))
337-
// let signatureBase64 = signature?.derRepresentation.base64URLEncodedString()
338-
//
339-
// return [headerBase64, payloadBase64, signatureBase64].compactMap { $0 }.joined(separator: ".")
340-
//}
341-
//
342-
//// MARK: - API Request
343-
//func fetchApps(jwt: String) {
344-
// let url = URL(string: "https://api.appstoreconnect.apple.com/v1/apps")!
345-
// var request = URLRequest(url: url)
346-
// request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
347-
//
348-
// let task = URLSession.shared.dataTask(with: request) { data, response, error in
349-
// if let error = error {
350-
// print("Request error: \(error)")
351-
// return
352-
// }
353-
//
354-
// guard let data = data,
355-
// let json = try? JSONSerialization.jsonObject(with: data, options: []) else {
356-
// print("Failed to parse response")
357-
// return
358-
// }
359-
//
360-
// print("Apps response:\n\(json)")
361-
// }
362-
//
363-
// task.resume()
364-
//}
365-
//
366-
367-
368-
//
369-
//func xxx() throws {
370-
// // MARK: - Main
371-
// if let privateKey = loadPrivateKey(from: privateKeyPath),
372-
// let jwt = generateJWT(using: privateKey) {
373-
// fetchApps(jwt: jwt)
374-
// RunLoop.main.run() // Keep CLI alive for async request
375-
// } else {
376-
// print("Failed to generate JWT")
377-
// }
378-
//}

0 commit comments

Comments
 (0)