diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44135fb..599a17f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ # This workflow will build a Swift project # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift -name: CI +name: "steelyard CI" on: push: @@ -12,20 +12,33 @@ on: - '*' workflow_dispatch: +concurrency: + group: ${{ github.ref_name }} + cancel-in-progress: true + jobs: - build: + macOS: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} + timeout-minutes: 10 strategy: + fail-fast: false matrix: - macos: - - 13 - xcode: - - latest-stable - runs-on: macos-${{ matrix.macos }} - steps: + include: + - xcode: latest + runsOn: macos-13 + name: "macOS 13, Xcode latest" + - xcode: latest-stable + runsOn: macos-13 + name: "macOS 13, Xcode latest-stable" + - xcode: "15.0" + runsOn: macos-13 + name: "macOS 13, Xcode 15.0, Swift 5.9.0" + steps: - uses: actions/checkout@v4 - name: Setup Xcode version uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: ${{ matrix.xcode }} - - name: Build - run: swift build -v + - name: ${{ matrix.name }} + run: swift build -v \ No newline at end of file diff --git a/.gitignore b/.gitignore index c5cee6f..addf767 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,4 @@ # Xcode -# -# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore .DS_Store .swiftpm/ @@ -9,23 +7,6 @@ ## User settings xcuserdata/ -## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) -*.xcscmblueprint -*.xccheckout - -## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) -build/ -DerivedData/ -*.moved-aside -*.pbxuser -!default.pbxuser -*.mode1v3 -!default.mode1v3 -*.mode2v3 -!default.mode2v3 -*.perspectivev3 -!default.perspectivev3 - ## Obj-C/Swift specific *.hmap @@ -39,56 +20,10 @@ timeline.xctimeline playground.xcworkspace # Swift Package Manager -# -# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. -# Packages/ -# Package.pins -Packages/**/Package.resolved -# *.xcodeproj -# -# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata -# hence it is not needed unless you have added a package configuration file to your project -# .swiftpm - .build/ -# CocoaPods -# -# We recommend against adding the Pods directory to your .gitignore. However -# you should judge for yourself, the pros and cons are mentioned at: -# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# -# Pods/ -# -# Add this line if you want to avoid checking in source code from the Xcode workspace -# *.xcworkspace - -# Carthage -# -# Add this line if you want to avoid checking in source code from Carthage dependencies. -# Carthage/Checkouts - -Carthage/Build/ - -# Accio dependency management -Dependencies/ -.accio/ - # fastlane -# -# It is recommended to not store the screenshots in the git repo. -# Instead, use fastlane to re-generate the screenshots whenever they are needed. -# For more information about the recommended setup visit: -# https://docs.fastlane.tools/best-practices/source-control/#source-control - fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png -fastlane/test_output - -# Code Injection -# -# After new code Injection tools there's a generated folder /iOSInjectionProject -# https://github.com/johnno1962/injectionforxcode - -iOSInjectionProject/ +fastlane/test_output \ No newline at end of file diff --git a/Package.resolved b/Package.resolved index 2941838..ad94da8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -27,6 +27,15 @@ "version" : "1.0.201" } }, + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", + "version" : "1.0.0" + } + }, { "identity" : "kituracontracts", "kind" : "remoteSourceControl", @@ -54,6 +63,14 @@ "version" : "4.0.1" } }, + { + "identity" : "steelyardcore", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mcrollin/SteelyardCore.git", + "state" : { + "revision" : "c9c53b47d776479e04680912f06822d018cd201c" + } + }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", @@ -63,6 +80,33 @@ "version" : "1.2.3" } }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "d1fd837326aa719bee979bdde1f53cd5797443eb", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "6155400cb15b0d99b2c257d57603d61d03a817a8", + "version" : "1.0.1" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies.git", + "state" : { + "revision" : "4e1eb6e28afe723286d8cc60611237ffbddba7c5", + "version" : "1.0.0" + } + }, { "identity" : "swift-http-types", "kind" : "remoteSourceControl", @@ -89,6 +133,24 @@ "revision" : "532d8b529501fb73a2455b179e0bbb6d49b652ed", "version" : "1.5.3" } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631", + "version" : "1.0.2" + } + }, + { + "identity" : "zip", + "kind" : "remoteSourceControl", + "location" : "https://github.com/marmelroy/Zip.git", + "state" : { + "revision" : "67fa55813b9e7b3b9acee9c0ae501def28746d76", + "version" : "2.1.2" + } } ], "version" : 2 diff --git a/Package.swift b/Package.swift index 5d918b0..d3b14c6 100644 --- a/Package.swift +++ b/Package.swift @@ -6,44 +6,46 @@ import PackageDescription let package = Package( name: "steelyard", platforms: [ - .macOS(.v13), + .macOS(.v14), ], products: [ - .executable(name: "steelyard", targets: ["SteelyardCommand"]), + .executable(name: "steelyard", targets: ["Steelyard"]), ], dependencies: [ - .package(path: "Packages/AppStoreConnect"), - .package(path: "Packages/Console"), - .package(path: "Packages/CommandLine"), - .package(path: "Packages/Platform"), + .package(url: "https://github.com/mcrollin/SteelyardCore.git", branch: "c9c53b47d776479e04680912f06822d018cd201c"), + .package(url: "https://github.com/apple/swift-argument-parser.git", .upToNextMajor(from: "1.2.0")), + .package(url: "https://github.com/onevcat/Rainbow", .upToNextMajor(from: "4.0.0")), ], targets: [ - .target( - name: "AppSizeFetcher", + .executableTarget( + name: "Steelyard", dependencies: [ - .product(name: "AppStoreConnect", package: "AppStoreConnect"), - .product(name: "Console", package: "Console"), - .product(name: "CommandLine", package: "CommandLine"), - .product(name: "Platform", package: "Platform"), + .target(name: "Archive"), + .target(name: "History"), ] ), .target( - name: "DataCommand", + name: "Archive", dependencies: [ - .target(name: "AppSizeFetcher"), + .target(name: "CommandLine"), + .product(name: "ApplicationArchive", package: "SteelyardCore"), + .product(name: "DesignComponents", package: "SteelyardCore"), + .product(name: "Platform", package: "SteelyardCore"), ] ), .target( - name: "GraphCommand", + name: "CommandLine", dependencies: [ - .target(name: "AppSizeFetcher"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Rainbow", package: "Rainbow"), ] ), - .executableTarget( - name: "SteelyardCommand", + .target( + name: "History", dependencies: [ - .target(name: "DataCommand"), - .target(name: "GraphCommand"), + .target(name: "CommandLine"), + .product(name: "AppStoreConnect", package: "SteelyardCore"), + .product(name: "Platform", package: "SteelyardCore"), ] ), ] diff --git a/Packages/AppStoreConnect/Package.swift b/Packages/AppStoreConnect/Package.swift deleted file mode 100644 index b9eddf4..0000000 --- a/Packages/AppStoreConnect/Package.swift +++ /dev/null @@ -1,38 +0,0 @@ -// swift-tools-version: 5.9 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "AppStoreConnect", - platforms: [ - .macOS(.v13), - .iOS(.v13), - ], - products: [ - .library(name: "AppStoreConnect", targets: ["AppStoreConnect"]), - ], - dependencies: [ - .package(url: "https://github.com/Kitura/Swift-JWT", .upToNextMajor(from: "4.0.1")), - .package(url: "https://github.com/apple/swift-http-types", .upToNextMajor(from: "1.0.0")), - ], - targets: [ - .target( - name: "AppStoreConnect", dependencies: [ - .target(name: "AppStoreConnectClient"), - .target(name: "AppStoreConnectModels"), - ] - ), - .target( - name: "AppStoreConnectClient", - dependencies: [ - .product(name: "SwiftJWT", package: "Swift-JWT"), - .product(name: "HTTPTypes", package: "swift-http-types"), - .product(name: "HTTPTypesFoundation", package: "swift-http-types"), - ] - ), - .target( - name: "AppStoreConnectModels" - ), - ] -) diff --git a/Packages/AppStoreConnect/Sources/AppStoreConnect/AppStoreConnect.swift b/Packages/AppStoreConnect/Sources/AppStoreConnect/AppStoreConnect.swift deleted file mode 100644 index 77d12d4..0000000 --- a/Packages/AppStoreConnect/Sources/AppStoreConnect/AppStoreConnect.swift +++ /dev/null @@ -1,212 +0,0 @@ -// -// Copyright © Marc Rollin. -// - -import AppStoreConnectClient -import AppStoreConnectModels -import Foundation -import HTTPTypes - -public actor AppStoreConnect { - - // MARK: Lifecycle - - public init(keyID: String, issuerID: String, privateKeyPath: String) throws { - client = try .init( - keyID: keyID, - issuerID: issuerID, - audience: "appstoreconnect-v1", - privateKeyPath: privateKeyPath - ) - - decoder.dateDecodingStrategy = .iso8601 - } - - // MARK: Public - - public func apps() async throws -> [Application] { - try await requestData(endpoint: .apps) - } - - public func app(appID: String) async throws -> Application { - try await requestData(endpoint: .app(appID: appID)) - } - - public func versions(app: Application, limit: Int) async throws -> [Version] { - try await requestIncluded(endpoint: .versions(appID: app.id, limit: limit)) - } - - public func build(version: Version) async throws -> Build { - let builds: [Build] = try await requestIncluded(endpoint: .build(versionID: version.id)) - return builds.first! - } - - public func builds(app: Application, limit: Int) async throws -> [Build] { - try await requestData(endpoint: .builds(appID: app.id, limit: limit)) - } - - public func buildBundles(build: Build) async throws -> [BuildBundle] { - try await requestIncluded(endpoint: .buildBundles(buildID: build.id)) - } - - public func buildBundleFileSizes(buildBundle: BuildBundle) async throws -> [BuildBundleFileSize] { - try await requestData(endpoint: .buildBundleFileSizes(bundleID: buildBundle.id)) - } - - public func sizes(versions: [Version], progress: ((Float) -> Void)?) async throws -> [BuildSizes] { - guard !versions.isEmpty else { return [] } - - return try await sizes(totalCount: versions.count, progress: progress) { group in - for version in versions { - group.addTask { - try await self.sizes( - byBuild: self.build(version: version), - version: version - ) - } - } - } - } - - public func sizes(builds: [Build], progress: ((Float) -> Void)?) async throws -> [BuildSizes] { - guard !builds.isEmpty else { return [] } - - return try await sizes(totalCount: builds.count, progress: progress) { group in - for build in builds { - group.addTask { - try await self.sizes( - byBuild: build, - version: nil - ) - } - } - } - } - - // MARK: Internal - - enum ConnectError: Error, CustomStringConvertible { - case missingBuildBundle(buildID: String, buildVersion: String) - - var description: String { - switch self { - case .missingBuildBundle(_, let buildVersion): - "No build bundle found for build #\(buildVersion)" - } - } - } - - static let baseURL = "api.appstoreconnect.apple.com" - static let version = "v1" - - // MARK: Private - - private enum Endpoint { - case apps - case app(appID: String) - case versions(appID: String, limit: Int) - case build(versionID: String) - case builds(appID: String, limit: Int) - case buildBundles(buildID: String) - case buildBundleFileSizes(bundleID: String) - - // MARK: Internal - - var httpMethod: HTTPRequest.Method { - .get - } - - var path: String { - switch self { - case .apps: - "apps?fields[apps]=bundleId,name" - case .app(let appID): - "apps/\(appID)?fields[apps]=bundleId,name" - case .versions(let appID, let limit): - "apps/\(appID)?include=appStoreVersions&fields[apps]=bundleId&fields[appStoreVersions]=versionString&limit[appStoreVersions]=\(limit)" - case .build(let versionID): - "appStoreVersions/\(versionID)?include=build&fields[appStoreVersions]=&fields[builds]=uploadedDate,version" - case .builds(let appID, let limit): - "builds?filter[app]=\(appID)&limit=\(limit)&fields[builds]=uploadedDate,version" - case .buildBundles(let buildID): - "builds/\(buildID)?include=buildBundles&fields[builds]=&fields[buildBundles]=bundleType" - case .buildBundleFileSizes(let bundleID): - "buildBundles/\(bundleID)/buildBundleFileSizes" - } - } - - var request: HTTPRequest { - .init( - method: httpMethod, - scheme: "https", - authority: AppStoreConnect.baseURL, - path: "/\(AppStoreConnect.version)/\(path)" - ) - } - } - - private let client: AppStoreConnectClient - private let decoder = JSONDecoder() - - private func sizes( - totalCount: Int, - progress: ((Float) -> Void)?, - addTasks: (inout ThrowingTaskGroup) -> Void - ) async throws -> [BuildSizes] { - try await withThrowingTaskGroup(of: BuildSizes.self) { group in - addTasks(&group) - - var buildSizes: [BuildSizes] = [] - let totalCount = Float(totalCount) - progress?(Float(buildSizes.count)/totalCount) - for try await sizes in group { - buildSizes.append(sizes) - progress?(Float(buildSizes.count)/totalCount) - } - buildSizes.sort { lhs, rhs in - lhs.build.uploadedDate < rhs.build.uploadedDate - } - - return buildSizes - } - } - - private func sizes(byBuild build: Build, version: Version?) async throws -> BuildSizes { - guard let mainBundle = try await buildBundles(build: build).first(where: { $0.bundleType == "APP" }) else { - throw ConnectError.missingBuildBundle(buildID: build.id, buildVersion: build.version) - } - - return .init( - version: version, - build: build, - fileSizes: try await buildBundleFileSizes(buildBundle: mainBundle) - ) - } - - private func requestData(endpoint: Endpoint) async throws -> DataType { - try await decoder.decode( - ResultData.self, - from: data(endpoint: endpoint) - ).data - } - - private func requestIncluded(endpoint: Endpoint) async throws -> IncludedType { - try await decoder.decode( - ResultIncluded.self, - from: data(endpoint: endpoint) - ).included - } - - private func data(endpoint: Endpoint) async throws -> Data { - do { - return try await client.send(request: endpoint.request) - } catch let error as AppStoreConnectClient.RequestError { - switch error { - case .http(_, let data): - throw (try? decoder.decode(ErrorResponse.self, from: data)) ?? error - } - } catch { - throw error - } - } -} diff --git a/Packages/AppStoreConnect/Sources/AppStoreConnect/Data/ErrorResponse.swift b/Packages/AppStoreConnect/Sources/AppStoreConnect/Data/ErrorResponse.swift deleted file mode 100644 index 2b3dcb7..0000000 --- a/Packages/AppStoreConnect/Sources/AppStoreConnect/Data/ErrorResponse.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// Copyright © Marc Rollin. -// - -import Foundation - -// MARK: - ErrorResponse - -struct ErrorResponse: Error, Codable, CustomStringConvertible { - let errors: [ErrorDetail] - - var description: String { - errors.map(\.detail).joined(separator: "\n") - } -} - -// MARK: - ErrorDetail - -struct ErrorDetail: Codable { - let status: String - let code: String - let title: String - let detail: String -} diff --git a/Packages/AppStoreConnect/Sources/AppStoreConnect/Data/ResultData.swift b/Packages/AppStoreConnect/Sources/AppStoreConnect/Data/ResultData.swift deleted file mode 100644 index 2d7354b..0000000 --- a/Packages/AppStoreConnect/Sources/AppStoreConnect/Data/ResultData.swift +++ /dev/null @@ -1,9 +0,0 @@ -// -// Copyright © Marc Rollin. -// - -import Foundation - -struct ResultData: Decodable { - let data: DataType -} diff --git a/Packages/AppStoreConnect/Sources/AppStoreConnect/Data/ResultIncluded.swift b/Packages/AppStoreConnect/Sources/AppStoreConnect/Data/ResultIncluded.swift deleted file mode 100644 index 9acc0df..0000000 --- a/Packages/AppStoreConnect/Sources/AppStoreConnect/Data/ResultIncluded.swift +++ /dev/null @@ -1,9 +0,0 @@ -// -// Copyright © Marc Rollin. -// - -import Foundation - -struct ResultIncluded: Decodable { - let included: IncludedType -} diff --git a/Packages/AppStoreConnect/Sources/AppStoreConnectClient/AppStoreConnectClient.swift b/Packages/AppStoreConnect/Sources/AppStoreConnectClient/AppStoreConnectClient.swift deleted file mode 100644 index 9724447..0000000 --- a/Packages/AppStoreConnect/Sources/AppStoreConnectClient/AppStoreConnectClient.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// Copyright © Marc Rollin. -// - -import Foundation -import HTTPTypes -import HTTPTypesFoundation -import SwiftJWT - -public actor AppStoreConnectClient { - - // MARK: Lifecycle - - public init(keyID: String, issuerID: String, audience: String, privateKeyPath: String) throws { - self.keyID = keyID - self.issuerID = issuerID - self.audience = audience - privateKey = try Self.privateKey(atPath: privateKeyPath) - } - - // MARK: Public - - public enum RequestError: Error, CustomStringConvertible { - case http(code: Int, data: Data) - - public var description: String { - switch self { - case .http(let code, _): - var explanation = "" - switch code { - case 400..<500: - explanation = "The request was invalid or cannot be otherwise served." - case 500..<600: - explanation = "An error occurred on the server while processing the request." - default: - explanation = "An unknown HTTP error occurred." - } - return "Network issue: Received HTTP status code \(code). \(explanation)" - } - } - } - - public func send(request: HTTPRequest) async throws -> Data { - let jwt = try jwtToken(httpMethod: request.method.rawValue, path: request.path!) - - var request = request - request.headerFields.append(.init(name: .authorization, value: "Bearer \(jwt)")) - - let (data, response) = try await URLSession.shared.data(for: request) - - switch response.status.kind { - case .successful: - return data - default: - throw RequestError.http( - code: response.status.code, - data: data - ) - } - } - - // MARK: Private - - private struct Claim: Claims { - let iss: String - var iat = Date() - var exp = Date().addingTimeInterval(1200) - let aud: String - let scope: [String] - } - - private let keyID: String - private let issuerID: String - private let audience: String - private let privateKey: Data - - private static func privateKey(atPath filePath: String) throws -> Data { - try Data(contentsOf: URL(fileURLWithPath: filePath)) - } - - // Helper function to create the JWT token - private func jwtToken(httpMethod: String, path: String) throws -> String { - let header = Header(typ: "JWT", kid: keyID) - let claim = Claim(iss: issuerID, aud: audience, scope: ["\(httpMethod) \(path)"]) - var jwt = JWT(header: header, claims: claim) - return try jwt.sign(using: .es256(privateKey: privateKey)) - } -} diff --git a/Packages/AppStoreConnect/Sources/AppStoreConnectModels/Application.swift b/Packages/AppStoreConnect/Sources/AppStoreConnectModels/Application.swift deleted file mode 100644 index 67186db..0000000 --- a/Packages/AppStoreConnect/Sources/AppStoreConnectModels/Application.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// Copyright © Marc Rollin. -// - -import Foundation - -public struct Application: Decodable { - - // MARK: Lifecycle - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - - let attributesContainer = try container.nestedContainer(keyedBy: AttributesCodingKeys.self, forKey: .attributes) - name = try attributesContainer.decode(String.self, forKey: .name) - bundleId = try attributesContainer.decode(String.self, forKey: .bundleId) - } - - // MARK: Public - - public let id: String - public let name: String - public let bundleId: String - - // MARK: Private - - private enum CodingKeys: String, CodingKey { - case id - case attributes - case relationships - } - - private enum AttributesCodingKeys: String, CodingKey { - case name - case bundleId - } -} diff --git a/Packages/AppStoreConnect/Sources/AppStoreConnectModels/Build.swift b/Packages/AppStoreConnect/Sources/AppStoreConnectModels/Build.swift deleted file mode 100644 index 3f4a43f..0000000 --- a/Packages/AppStoreConnect/Sources/AppStoreConnectModels/Build.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// Copyright © Marc Rollin. -// - -import Foundation - -public struct Build: Decodable { - - // MARK: Lifecycle - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - - let attributesContainer = try container.nestedContainer(keyedBy: AttributesCodingKeys.self, forKey: .attributes) - version = try attributesContainer.decode(String.self, forKey: .version) - uploadedDate = try attributesContainer.decode(Date.self, forKey: .uploadedDate) - } - - // MARK: Public - - public let id: String - public let version: String - public let uploadedDate: Date - - // MARK: Private - - private enum CodingKeys: String, CodingKey { - case id - case attributes - } - - private enum AttributesCodingKeys: String, CodingKey { - case version - case uploadedDate - } -} diff --git a/Packages/AppStoreConnect/Sources/AppStoreConnectModels/BuildBundle.swift b/Packages/AppStoreConnect/Sources/AppStoreConnectModels/BuildBundle.swift deleted file mode 100644 index 793e8d3..0000000 --- a/Packages/AppStoreConnect/Sources/AppStoreConnectModels/BuildBundle.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// Copyright © Marc Rollin. -// - -import Foundation - -public struct BuildBundle: Decodable { - - // MARK: Lifecycle - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - - let attributesContainer = try container.nestedContainer(keyedBy: AttributesCodingKeys.self, forKey: .attributes) - bundleType = try attributesContainer.decode(String.self, forKey: .bundleType) - } - - // MARK: Public - - public let id: String - public let bundleType: String - - // MARK: Private - - private enum CodingKeys: String, CodingKey { - case id - case attributes - } - - private enum AttributesCodingKeys: String, CodingKey { - case bundleType - } -} diff --git a/Packages/AppStoreConnect/Sources/AppStoreConnectModels/BuildBundleFileSize.swift b/Packages/AppStoreConnect/Sources/AppStoreConnectModels/BuildBundleFileSize.swift deleted file mode 100644 index baf040a..0000000 --- a/Packages/AppStoreConnect/Sources/AppStoreConnectModels/BuildBundleFileSize.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// Copyright © Marc Rollin. -// - -import Foundation - -public struct BuildBundleFileSize: Decodable, Identifiable { - - // MARK: Lifecycle - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let attributesContainer = try container.nestedContainer(keyedBy: AttributesCodingKeys.self, forKey: .attributes) - - id = try container.decode(String.self, forKey: .id) - deviceModel = try attributesContainer.decode(String.self, forKey: .deviceModel) - osVersion = try attributesContainer.decode(String.self, forKey: .osVersion) - downloadBytes = try attributesContainer.decode(Int.self, forKey: .downloadBytes) - installBytes = try attributesContainer.decode(Int.self, forKey: .installBytes) - } - - // MARK: Public - - public let id: String - public let deviceModel: String - public let osVersion: String - public let downloadBytes: Int - public let installBytes: Int - - // MARK: Private - - private enum CodingKeys: String, CodingKey { - case id - case attributes - } - - private enum AttributesCodingKeys: String, CodingKey { - case deviceModel - case osVersion - case downloadBytes - case installBytes - } -} diff --git a/Packages/AppStoreConnect/Sources/AppStoreConnectModels/BuildSizes.swift b/Packages/AppStoreConnect/Sources/AppStoreConnectModels/BuildSizes.swift deleted file mode 100644 index 1fdebf3..0000000 --- a/Packages/AppStoreConnect/Sources/AppStoreConnectModels/BuildSizes.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// Copyright © Marc Rollin. -// - -import Foundation - -public struct BuildSizes { - public let version: Version? - public let build: Build - public let fileSizes: [BuildBundleFileSize] - - public init(version: Version? = nil, build: Build, fileSizes: [BuildBundleFileSize]) { - self.version = version - self.build = build - self.fileSizes = fileSizes - } -} diff --git a/Packages/AppStoreConnect/Sources/AppStoreConnectModels/Version.swift b/Packages/AppStoreConnect/Sources/AppStoreConnectModels/Version.swift deleted file mode 100644 index e1ffc76..0000000 --- a/Packages/AppStoreConnect/Sources/AppStoreConnectModels/Version.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// Copyright © Marc Rollin. -// - -import Foundation - -public struct Version: Decodable { - public let id: String - public let version: String - - private enum CodingKeys: String, CodingKey { - case id - case attributes - } - - private enum AttributesCodingKeys: String, CodingKey { - case versionString - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - - let attributesContainer = try container.nestedContainer(keyedBy: AttributesCodingKeys.self, forKey: .attributes) - version = try attributesContainer.decode(String.self, forKey: .versionString) - } -} diff --git a/Packages/CommandLine/Package.swift b/Packages/CommandLine/Package.swift deleted file mode 100644 index 6b3f1f1..0000000 --- a/Packages/CommandLine/Package.swift +++ /dev/null @@ -1,22 +0,0 @@ -// swift-tools-version: 5.9 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "CommandLine", - products: [ - .library(name: "CommandLine", targets: ["CommandLine"]), - ], - dependencies: [ - .package(url: "https://github.com/apple/swift-argument-parser.git", .upToNextMajor(from: "1.2.0")), - ], - targets: [ - .target( - name: "CommandLine", - dependencies: [ - .product(name: "ArgumentParser", package: "swift-argument-parser"), - ] - ), - ] -) diff --git a/Packages/CommandLine/Sources/CommandLine/CommandLine.swift b/Packages/CommandLine/Sources/CommandLine/CommandLine.swift deleted file mode 100644 index 5a851db..0000000 --- a/Packages/CommandLine/Sources/CommandLine/CommandLine.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Copyright © Marc Rollin. -// - -import ArgumentParser -import Foundation - -public enum CommandLine { } diff --git a/Packages/Console/Package.swift b/Packages/Console/Package.swift deleted file mode 100644 index 22158df..0000000 --- a/Packages/Console/Package.swift +++ /dev/null @@ -1,22 +0,0 @@ -// swift-tools-version: 5.9 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "Console", - products: [ - .library(name: "Console", targets: ["Console"]), - ], - dependencies: [ - .package(url: "https://github.com/onevcat/Rainbow", .upToNextMajor(from: "4.0.0")), - ], - targets: [ - .target( - name: "Console", - dependencies: [ - .product(name: "Rainbow", package: "Rainbow"), - ] - ), - ] -) diff --git a/Packages/Platform/Package.swift b/Packages/Platform/Package.swift deleted file mode 100644 index d77acaf..0000000 --- a/Packages/Platform/Package.swift +++ /dev/null @@ -1,20 +0,0 @@ -// swift-tools-version: 5.9 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "Platform", - platforms: [ - .macOS(.v13), - .iOS(.v13), - ], - products: [ - .library(name: "Platform", targets: ["Platform"]), - ], - targets: [ - .target( - name: "Platform" - ), - ] -) diff --git a/Packages/Platform/Sources/Platform/Extensions/Comparable+clamped.swift b/Packages/Platform/Sources/Platform/Extensions/Comparable+clamped.swift deleted file mode 100644 index 004e278..0000000 --- a/Packages/Platform/Sources/Platform/Extensions/Comparable+clamped.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// Copyright © Marc Rollin. -// - -import Foundation - -extension Comparable { - public func clamped(to limits: ClosedRange) -> Self { - min(max(self, limits.lowerBound), limits.upperBound) - } -} diff --git a/Packages/Platform/Sources/Platform/Extensions/View+render.swift b/Packages/Platform/Sources/Platform/Extensions/View+render.swift deleted file mode 100644 index ba22bb5..0000000 --- a/Packages/Platform/Sources/Platform/Extensions/View+render.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// Copyright © Marc Rollin. -// - -import SwiftUI -import UniformTypeIdentifiers - -// MARK: - ViewRenderer - -@MainActor -private final class ViewRenderer { - - // MARK: Lifecycle - - init(content: Content) { - self.content = content - } - - // MARK: Internal - - func renderImage() async -> CGImage? { - let renderer = ImageRenderer(content: content) - renderer.scale = 2 - - return renderer.cgImage - } - - // MARK: Private - - private let content: Content - -} - -// MARK: - RenderingError - -public enum RenderingError: Error, CustomStringConvertible { - case imageRender - case diskWrite(url: URL) - - public var description: String { - switch self { - case .imageRender: - "Could not render image" - case .diskWrite(let url): - "Could not write to disk at path \(url.absoluteString)" - } - } -} - -extension View { - - public func renderImage() async throws -> CGImage { - guard let image = await ViewRenderer(content: self).renderImage() else { - throw RenderingError.imageRender - } - - return image - } - - public func renderImage(to filePath: String? = nil) async throws -> URL { - let image = try await renderImage() - - let url: URL - if let filePath { - url = URL(fileURLWithPath: filePath) - } else { - url = FileManager.default - .temporaryDirectory - .appendingPathComponent(UUID().uuidString) - .appendingPathExtension("png") - } - - let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)! - CGImageDestinationAddImage(destination, image, nil) - guard CGImageDestinationFinalize(destination) else { - throw RenderingError.diskWrite(url: url) - } - - return url - } -} diff --git a/Sources/AppSizeFetcher/AppSizeFetcher.swift b/Sources/AppSizeFetcher/AppSizeFetcher.swift deleted file mode 100644 index ce37961..0000000 --- a/Sources/AppSizeFetcher/AppSizeFetcher.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// Copyright © Marc Rollin. -// - -import AppStoreConnect -import AppStoreConnectModels -import ArgumentParser -import Console -import Foundation - -// MARK: - AppSizeFetcher - -public protocol AppSizeFetcher { - var appStoreConnectArguments: AppStoreConnectArguments { get } - var fetchOptions: FetchOptions { get } - var exportOptions: ExportOptions { get } - var runOptions: RunOptions { get } - - func fetchAppSize() async throws -> (Application, [BuildSizes]) -} - -extension AppSizeFetcher { - - public func fetchAppSize() async throws -> (Application, [BuildSizes]) { - let appStoreConnect: AppStoreConnect = try AppStoreConnect( - keyID: appStoreConnectArguments.keyID, - issuerID: appStoreConnectArguments.issuerID, - privateKeyPath: appStoreConnectArguments.privateKeyPath - ) - - let app = try await appStoreConnect.app(appID: appStoreConnectArguments.appID) - Console.info("Successfully identified app with ID \(app.id): \"\(app.name)\"") - - let buildsSizes: [BuildSizes] - - if fetchOptions.byVersion { - Console.debug("Fetching \(fetchOptions.limit) most recent versions (may take some time)…") - let versions = try await appStoreConnect.versions(app: app, limit: fetchOptions.limit) - Console.info("Retrieved \(versions.count) most recent versions (maximum limit set to \(fetchOptions.limit))") - - Console.debug("") - buildsSizes = try await appStoreConnect.sizes(versions: versions) { progress in - Console.debug( - "Fetching individual version sizes… \(Console.progress(progress, columns: 40))", - prefix: "\u{1B}[1A\u{1B}[K" - ) - } - Console.info("Fetched file sizes for \(buildsSizes.count) builds") - } else { - Console.debug("Fetching \(fetchOptions.limit) most recent builds…") - let builds = try await appStoreConnect.builds(app: app, limit: fetchOptions.limit) - Console.info("Retrieved \(builds.count) most recent builds (maximum limit set to \(fetchOptions.limit))") - - Console.debug("") - buildsSizes = try await appStoreConnect.sizes(builds: builds) { progress in - Console.debug( - "Fetching individual build sizes… \(Console.progress(progress, columns: 40))", - prefix: "\u{1B}[1A\u{1B}[K" - ) - } - Console.info("Fetched file sizes for \(buildsSizes.count) versions") - } - - return (app, buildsSizes) - } -} - -extension AppSizeFetcher where Self: ParsableCommand { - - public func validate() throws { - // Validate Key ID and Issuer ID - guard !appStoreConnectArguments.keyID.isEmpty, !appStoreConnectArguments.issuerID.isEmpty else { - throw ValidationError("Both keyID and issuerID must be provided.") - } - - // Validate Private Key Path - let privateKeyURL = URL(fileURLWithPath: appStoreConnectArguments.privateKeyPath) - guard FileManager.default.fileExists(atPath: privateKeyURL.path) else { - throw ValidationError("No file exists at the provided privateKeyPath: \(appStoreConnectArguments.privateKeyPath)") - } - - // Validate App ID - guard !appStoreConnectArguments.appID.isEmpty else { - throw ValidationError("App ID must be provided.") - } - - // Validate limit - if fetchOptions.byVersion { - guard FetchOptions.versionsRangeLimit ~= fetchOptions.limit else { - throw ValidationError( - "Provide a limit between \(FetchOptions.versionsRangeLimit.lowerBound) and \(FetchOptions.versionsRangeLimit.upperBound)" - ) - } - } else { - guard FetchOptions.buildsRangeLimit ~= fetchOptions.limit else { - throw ValidationError( - "Provide a limit between \(FetchOptions.buildsRangeLimit.lowerBound) and \(FetchOptions.buildsRangeLimit.upperBound)" - ) - } - } - } -} diff --git a/Sources/Archive/Analyze/AnalyzeBuildCommand.swift b/Sources/Archive/Analyze/AnalyzeBuildCommand.swift new file mode 100644 index 0000000..90b03a8 --- /dev/null +++ b/Sources/Archive/Analyze/AnalyzeBuildCommand.swift @@ -0,0 +1,54 @@ +// +// Copyright © Marc Rollin. +// + +import ApplicationArchive +import ArgumentParser +import CommandLine +import Foundation + +// MARK: - AnalyzeBuildCommand + +struct AnalyzeBuildCommand: AsyncParsableCommand { + + // MARK: Lifecycle + + init() { } + + // MARK: Internal + + static var configuration = CommandConfiguration( + commandName: "analyze", + abstract: "Perform a detailed analysis of an app archive." + ) + + @Argument(help: "The file path to the .ipa or .app file.", transform: URL.init(fileURLWithPath:)) + var path: URL + + @OptionGroup var exportOptions: ExportOptions + @OptionGroup var consoleOptions: ConsoleOptions + + func validate() throws { + guard FileManager.default.fileExists(atPath: path.path) else { + throw ValidationError("Invalid filepath \(path.path)") + } + } + + func run() async throws { + Console.configure(options: consoleOptions) + + print("\n\n===DUPLICATES===\n") + try await Archive(from: path) + .findTopLevelDuplicates() + .forEach { duplicate in + guard let first = duplicate.first else { + return + } + let potentialGain = first.sizeInBytes * (duplicate.count - 1) + print("--- \(potentialGain.formattedBytes())") + duplicate.forEach { node in + print(node) + } + } + } +} diff --git a/Sources/Archive/ArchiveCommand.swift b/Sources/Archive/ArchiveCommand.swift new file mode 100644 index 0000000..c2eead5 --- /dev/null +++ b/Sources/Archive/ArchiveCommand.swift @@ -0,0 +1,17 @@ +// +// Copyright © Marc Rollin. +// + +import ArgumentParser +import Foundation + +public struct ArchiveCommand: AsyncParsableCommand { + + public init() { } + + public static var configuration = CommandConfiguration( + commandName: "archive", + abstract: "Deep-dive into the size components of an app archive.", + subcommands: [InspectBuildCommand.self, AnalyzeBuildCommand.self] + ) +} diff --git a/Sources/Archive/Inspect/InspectBuildCommand.swift b/Sources/Archive/Inspect/InspectBuildCommand.swift new file mode 100644 index 0000000..c00c092 --- /dev/null +++ b/Sources/Archive/Inspect/InspectBuildCommand.swift @@ -0,0 +1,57 @@ +// +// Copyright © Marc Rollin. +// + +import ApplicationArchive +import ArgumentParser +import CommandLine +import DesignComponents +import Foundation +import Platform +import SwiftUI + +// MARK: - InspectBuildCommand + +struct InspectBuildCommand: AsyncParsableCommand { + + // MARK: Lifecycle + + init() { } + + // MARK: Internal + + static var configuration = CommandConfiguration( + commandName: "inspect", + abstract: "Display a detailed size inspection of app archive." + ) + + @Argument(help: "The file path to the .ipa or .app file.", transform: URL.init(fileURLWithPath:)) + var path: URL + + @OptionGroup var exportOptions: ExportOptions + @OptionGroup var consoleOptions: ConsoleOptions + @OptionGroup var themeOptions: ThemeOptions + + func validate() throws { + guard FileManager.default.fileExists(atPath: path.path) else { + throw ValidationError("Invalid filepath \(path.path)") + } + } + + func run() async throws { + Console.configure(options: consoleOptions) + + let archive = try await Archive(from: path) + let treeMap = TreeMap(node: archive.root, duplicates: archive.duplicateIDs) + .colorScheme(themeOptions.colorScheme) + .frame(width: 3840, height: 2160) + + let url: URL = switch exportOptions.format { + case .pdf: try await treeMap.savePDF(to: exportOptions.output) + case .png: try await treeMap.saveImage(to: exportOptions.output) + } + + Console.success("Inspect \(exportOptions.format.rawValue.uppercased()) file saved at: \(url.absoluteString)") + } + +} diff --git a/Sources/CommandLine/ColorScheme+Argument.swift b/Sources/CommandLine/ColorScheme+Argument.swift new file mode 100644 index 0000000..1e9d7e9 --- /dev/null +++ b/Sources/CommandLine/ColorScheme+Argument.swift @@ -0,0 +1,31 @@ +// +// Copyright © Marc Rollin. +// + +import ArgumentParser +import Foundation +import SwiftUI + +@available(macOS 10.15, *) +extension ColorScheme: ExpressibleByArgument { + + // MARK: Lifecycle + + public init?(argument: String) { + switch Argument(rawValue: argument) { + case .dark?: + self = .dark + case .light?: + self = .light + default: + return nil + } + } + + // MARK: Public + + public enum Argument: String { + case dark, light + } + +} diff --git a/Packages/Console/Sources/Console/Console.swift b/Sources/CommandLine/Console.swift similarity index 76% rename from Packages/Console/Sources/Console/Console.swift rename to Sources/CommandLine/Console.swift index 0cb0b10..c352534 100644 --- a/Packages/Console/Sources/Console/Console.swift +++ b/Sources/CommandLine/Console.swift @@ -9,7 +9,10 @@ public enum Console { // MARK: Public - public static var verbose = false + public static func configure(options: ConsoleOptions) { + verbose = options.verbose + silence = options.silence + } public static func debug(_ message: any StringProtocol, prefix: String? = nil) { log(.debug, message, prefix: prefix) @@ -19,6 +22,10 @@ public enum Console { log(.info, message, prefix: prefix) } + public static func notice(_ message: any StringProtocol, prefix: String? = nil) { + log(.notice, message, prefix: prefix) + } + public static func success(_ message: any StringProtocol, prefix: String? = nil) { log(.success, message, prefix: prefix) } @@ -39,21 +46,28 @@ public enum Console { let completedSection = String(repeating: "=", count: completedBars) let remainingSection = String(repeating: " ", count: remainingBars) let percentagePadding = String(repeating: " ", count: paddingForPercentage) - return "[\(completedSection)\(remainingSection)]\(percentagePadding)\(formattedPercentage)" + let status = progress < 1 ? "↕️" : "✅" + return "[\(completedSection)\(remainingSection)]\(percentagePadding)\(formattedPercentage) \(status)" } // MARK: Private private enum Level { - case debug, info, success, warn, error + case debug, info, notice, success, warn, error } + private static var verbose = false + private static var silence = false + private static func log(_ level: Level, _ message: any StringProtocol, prefix: String? = nil) { + guard !silence else { return } let prefix = prefix ?? "" switch level { case .debug where verbose: + print("\(prefix)\(String(message).magenta)") + case .info: print("\(prefix)\(String(message))") - case .info where verbose: + case .notice: print("\(prefix)\(String(message).blue)") case .success: print("\(prefix)\(String(message).bold.green)") diff --git a/Sources/AppSizeFetcher/Arguments/RunOptions.swift b/Sources/CommandLine/Options/ConsoleOptions.swift similarity index 51% rename from Sources/AppSizeFetcher/Arguments/RunOptions.swift rename to Sources/CommandLine/Options/ConsoleOptions.swift index b7d6c41..2ba1722 100644 --- a/Sources/AppSizeFetcher/Arguments/RunOptions.swift +++ b/Sources/CommandLine/Options/ConsoleOptions.swift @@ -5,9 +5,14 @@ import ArgumentParser import Foundation -public struct RunOptions: ParsableArguments { +// MARK: - ConsoleOptions + +public struct ConsoleOptions: ParsableArguments { public init() { } @Flag(name: .shortAndLong, help: "Display all information messages.") public var verbose = false + + @Flag(name: .shortAndLong, help: "Silence the output (overrides verbose mode).") + public var silence = false } diff --git a/Sources/CommandLine/Options/ExportOptions.swift b/Sources/CommandLine/Options/ExportOptions.swift new file mode 100644 index 0000000..8da0f3e --- /dev/null +++ b/Sources/CommandLine/Options/ExportOptions.swift @@ -0,0 +1,40 @@ +// +// Copyright © Marc Rollin. +// + +import ArgumentParser +import Foundation + +// MARK: - ExportOptions + +public struct ExportOptions: ParsableArguments { + public init() { } + + @Option(name: .shortAndLong, help: "Specify the destination path.", transform: URL.init(fileURLWithPath:)) + public var output: URL? + + @Option(name: .shortAndLong, help: "The output format") + public var format = Format.defaultValue +} + +// MARK: - ExportFormat + +public protocol ExportFormat: ExpressibleByArgument { + static var defaultValue: Self { get } +} + +// MARK: - GraphicExportFormat + +public enum GraphicExportFormat: String, ExportFormat { + case png, pdf + + public static var defaultValue: Self = .pdf +} + +// MARK: - DataExportFormat + +public enum DataExportFormat: String, ExportFormat { + case json + + public static var defaultValue: Self = .json +} diff --git a/Sources/CommandLine/Options/ThemeOptions.swift b/Sources/CommandLine/Options/ThemeOptions.swift new file mode 100644 index 0000000..009f78b --- /dev/null +++ b/Sources/CommandLine/Options/ThemeOptions.swift @@ -0,0 +1,15 @@ +// +// Copyright © Marc Rollin. +// + +import ArgumentParser +import Foundation +import SwiftUI + +@available(macOS 10.15, *) +public struct ThemeOptions: ParsableArguments { + public init() { } + + @Option(name: .customLong("theme"), help: "The visual color theme applied.") + public var colorScheme: ColorScheme = .dark +} diff --git a/Sources/DataCommand/DataCommand.swift b/Sources/DataCommand/DataCommand.swift deleted file mode 100644 index c4d7d6c..0000000 --- a/Sources/DataCommand/DataCommand.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// Copyright © Marc Rollin. -// - -import AppSizeFetcher -import AppStoreConnectModels -import ArgumentParser -import Console -import Foundation -import Platform - -// MARK: - DataCommand - -public struct DataCommand: AsyncParsableCommand, AppSizeFetcher { - - // MARK: Lifecycle - - public init() { } - - // MARK: Public - - public static var configuration = CommandConfiguration( - commandName: "data", - abstract: "Produce a JSON file with in-depth size metrics for a specific app." - ) - - @OptionGroup public var appStoreConnectArguments: AppStoreConnectArguments - @OptionGroup public var fetchOptions: FetchOptions - @OptionGroup public var exportOptions: ExportOptions - @OptionGroup public var runOptions: RunOptions - - public func run() async throws { - Console.verbose = runOptions.verbose - let (app, buildsSizes) = try await fetchAppSize() - let data = AppSizeData( - app: app, - buildsSizes: buildsSizes, - includeDownloadSize: exportOptions.includeDownloadSize, - includeInstallSize: exportOptions.includeInstallSize - ) - let url = try await data.write(to: exportOptions.output) - Console.success("Data successfully saved at: \(url.absoluteString)") - } -} diff --git a/Sources/GraphCommand/GraphCommand.swift b/Sources/GraphCommand/GraphCommand.swift deleted file mode 100644 index 4b70a0b..0000000 --- a/Sources/GraphCommand/GraphCommand.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// Copyright © Marc Rollin. -// - -import AppSizeFetcher -import ArgumentParser -import Console -import Foundation -import Platform - -public struct GraphCommand: AsyncParsableCommand, AppSizeFetcher { - - // MARK: Lifecycle - - public init() { } - - // MARK: Public - - public static var configuration = CommandConfiguration( - commandName: "graph", - abstract: "Create a PNG image that displays historical size graphs for a specific app." - ) - - @OptionGroup public var appStoreConnectArguments: AppStoreConnectArguments - @OptionGroup public var fetchOptions: FetchOptions - @OptionGroup public var exportOptions: ExportOptions - @OptionGroup public var runOptions: RunOptions - - @Flag(help: "Set to dark color scheme.") - public var darkScheme = false - - @Option(help: "The reference device to highlight in the charts.") - public var referenceDeviceIdentifier = "iPhone12,1" - - public func run() async throws { - Console.verbose = runOptions.verbose - let (app, buildsSizes) = try await fetchAppSize() - let dashboard = Dashboard( - model: .init( - app: app, - buildsSizes: buildsSizes, - includeDownloadSize: exportOptions.includeDownloadSize, - includeInstallSize: exportOptions.includeInstallSize, - referenceDeviceIdentifier: referenceDeviceIdentifier, - darkScheme: darkScheme - ) - ) - let url = try await dashboard.renderImage(to: exportOptions.output) - Console.success("Image successfully saved at: \(url.absoluteString)") - } -} diff --git a/Sources/AppSizeFetcher/Arguments/AppStoreConnectArguments.swift b/Sources/History/BuildSizeHistory/Arguments/AppStoreConnectArguments.swift similarity index 51% rename from Sources/AppSizeFetcher/Arguments/AppStoreConnectArguments.swift rename to Sources/History/BuildSizeHistory/Arguments/AppStoreConnectArguments.swift index dbec5d4..1f3ca1d 100644 --- a/Sources/AppSizeFetcher/Arguments/AppStoreConnectArguments.swift +++ b/Sources/History/BuildSizeHistory/Arguments/AppStoreConnectArguments.swift @@ -7,18 +7,18 @@ import Foundation // MARK: - AppSizeFetcher -public struct AppStoreConnectArguments: ParsableArguments { - public init() { } +struct AppStoreConnectArguments: ParsableArguments { + init() { } @Argument(help: "The key ID from the Apple Developer portal.") - public var keyID: String + var keyID: String @Argument(help: "The issuer ID from the App Store Connect organization.") - public var issuerID: String + var issuerID: String - @Argument(help: "The path to the .p8 private key file.") - public var privateKeyPath: String + @Argument(help: "The path to the .p8 private key file.", transform: URL.init(fileURLWithPath:)) + var privateKey: URL @Argument(help: "The App ID.") - public var appID: String + var appID: String } diff --git a/Sources/AppSizeFetcher/Arguments/FetchOptions.swift b/Sources/History/BuildSizeHistory/Arguments/FetchOptions.swift similarity index 83% rename from Sources/AppSizeFetcher/Arguments/FetchOptions.swift rename to Sources/History/BuildSizeHistory/Arguments/FetchOptions.swift index 34e3676..27d3c4f 100644 --- a/Sources/AppSizeFetcher/Arguments/FetchOptions.swift +++ b/Sources/History/BuildSizeHistory/Arguments/FetchOptions.swift @@ -5,13 +5,16 @@ import ArgumentParser import Foundation -public struct FetchOptions: ParsableArguments { +struct FetchOptions: ParsableArguments { // MARK: Lifecycle - public init() { } + init() { } - // MARK: Public + // MARK: Internal + + static let versionsRangeLimit: ClosedRange = 1...50 + static let buildsRangeLimit: ClosedRange = 1...200 @Option( name: .shortAndLong, @@ -24,13 +27,9 @@ public struct FetchOptions: ParsableArguments { """ ) ) - public var limit = 30 + var limit = 30 @Flag(help: "Fetch sizes categorized by version, not build. Slower to retrieve.") - public var byVersion = false + var byVersion = false - // MARK: Internal - - static let versionsRangeLimit: ClosedRange = 1...50 - static let buildsRangeLimit: ClosedRange = 1...200 } diff --git a/Sources/AppSizeFetcher/Arguments/ExportOptions.swift b/Sources/History/BuildSizeHistory/Arguments/SizeOptions.swift similarity index 50% rename from Sources/AppSizeFetcher/Arguments/ExportOptions.swift rename to Sources/History/BuildSizeHistory/Arguments/SizeOptions.swift index 49ab0ed..ec01431 100644 --- a/Sources/AppSizeFetcher/Arguments/ExportOptions.swift +++ b/Sources/History/BuildSizeHistory/Arguments/SizeOptions.swift @@ -5,15 +5,12 @@ import ArgumentParser import Foundation -public struct ExportOptions: ParsableArguments { - public init() { } +struct SizeOptions: ParsableArguments { + init() { } @Flag(name: .customLong("download-size"), inversion: .prefixedNo, help: "Include download sizes.") - public var includeDownloadSize = true + var includeDownloadSize = true @Flag(name: .customLong("install-size"), inversion: .prefixedNo, help: "Include install sizes.") - public var includeInstallSize = true - - @Option(name: .shortAndLong, help: "Specify the destination path for the generated file.") - public var output: String? + var includeInstallSize = true } diff --git a/Sources/History/BuildSizeHistory/BuildSizeHistory.swift b/Sources/History/BuildSizeHistory/BuildSizeHistory.swift new file mode 100644 index 0000000..9285c64 --- /dev/null +++ b/Sources/History/BuildSizeHistory/BuildSizeHistory.swift @@ -0,0 +1,11 @@ +// +// Copyright © Marc Rollin. +// + +import AppStoreConnect +import Foundation + +struct BuildSizeHistory { + let app: Application + let sizes: [BuildSizes] +} diff --git a/Sources/History/BuildSizeHistory/HistoricalDataFetcher.swift b/Sources/History/BuildSizeHistory/HistoricalDataFetcher.swift new file mode 100644 index 0000000..30637e6 --- /dev/null +++ b/Sources/History/BuildSizeHistory/HistoricalDataFetcher.swift @@ -0,0 +1,110 @@ +// +// Copyright © Marc Rollin. +// + +import AppStoreConnect +import ArgumentParser +import CommandLine +import Foundation + +// MARK: - BuildSizeHistoryFetcher + +protocol BuildSizeHistoryFetcher { + associatedtype Format: ExportFormat + + var appStoreConnectArguments: AppStoreConnectArguments { get } + var fetchOptions: FetchOptions { get } + var sizeOptions: SizeOptions { get } + var exportOptions: ExportOptions { get } + var consoleOptions: ConsoleOptions { get } + + func fetchHistory() async throws -> BuildSizeHistory +} + +extension BuildSizeHistoryFetcher { + + // MARK: Internal + + func fetchHistory() async throws -> BuildSizeHistory { + let appStoreConnect: AppStoreConnect = try AppStoreConnect( + keyID: appStoreConnectArguments.keyID, + issuerID: appStoreConnectArguments.issuerID, + privateKey: appStoreConnectArguments.privateKey + ) + + let app = try await appStoreConnect.app(appID: appStoreConnectArguments.appID) + Console.notice("Successfully identified app with ID \(app.id): \"\(app.name)\"") + + let sizes: [BuildSizes] + + if fetchOptions.byVersion { + Console.notice("Fetching \(fetchOptions.limit) most recent versions (may take some time)…") + let versions = try await appStoreConnect.versions(app: app, limit: fetchOptions.limit) + Console.notice("Retrieved \(versions.count) most recent versions (maximum limit set to \(fetchOptions.limit))") + + Console.notice("Fetching individual version sizes (may take a bit of time)") + printProgress(progress: 0, clearPreviousLine: false) + sizes = try await appStoreConnect.sizes(versions: versions) { progress in + printProgress(progress: progress) + } + Console.notice("Fetched file sizes for \(sizes.count) builds") + } else { + Console.notice("Fetching \(fetchOptions.limit) most recent builds…") + let builds = try await appStoreConnect.builds(app: app, limit: fetchOptions.limit) + Console.notice("Retrieved \(builds.count) most recent builds (maximum limit set to \(fetchOptions.limit))") + + Console.notice("Fetching individual build sizes") + printProgress(progress: 0, clearPreviousLine: false) + sizes = try await appStoreConnect.sizes(builds: builds) { progress in + printProgress(progress: progress) + } + Console.notice("Fetched file sizes for \(sizes.count) versions") + } + + return .init(app: app, sizes: sizes) + } + + // MARK: Private + + private func printProgress(progress: Float, clearPreviousLine: Bool = true) { + Console.info( + "\(Console.progress(progress, columns: 40))", + prefix: "\u{1B}[1A\u{1B}[K" + ) + } +} + +extension BuildSizeHistoryFetcher where Self: ParsableCommand { + + func validate() throws { + // Validate Key ID and Issuer ID + guard !appStoreConnectArguments.keyID.isEmpty, !appStoreConnectArguments.issuerID.isEmpty else { + throw ValidationError("Both keyID and issuerID must be provided.") + } + + // Validate Private Key Path + guard FileManager.default.fileExists(atPath: appStoreConnectArguments.privateKey.path) else { + throw ValidationError("No file exists at the provided path: \(appStoreConnectArguments.privateKey)") + } + + // Validate App ID + guard !appStoreConnectArguments.appID.isEmpty else { + throw ValidationError("App ID must be provided.") + } + + // Validate limit + if fetchOptions.byVersion { + guard FetchOptions.versionsRangeLimit ~= fetchOptions.limit else { + throw ValidationError( + "Provide a limit between \(FetchOptions.versionsRangeLimit.lowerBound) and \(FetchOptions.versionsRangeLimit.upperBound)" + ) + } + } else { + guard FetchOptions.buildsRangeLimit ~= fetchOptions.limit else { + throw ValidationError( + "Provide a limit between \(FetchOptions.buildsRangeLimit.lowerBound) and \(FetchOptions.buildsRangeLimit.upperBound)" + ) + } + } + } +} diff --git a/Sources/DataCommand/AppSizeData.swift b/Sources/History/Export/AppSizeData.swift similarity index 82% rename from Sources/DataCommand/AppSizeData.swift rename to Sources/History/Export/AppSizeData.swift index 6505d70..51fe333 100644 --- a/Sources/DataCommand/AppSizeData.swift +++ b/Sources/History/Export/AppSizeData.swift @@ -2,7 +2,7 @@ // Copyright © Marc Rollin. // -import AppStoreConnectModels +import AppStoreConnect import Foundation struct AppSizeData: Codable { @@ -60,18 +60,13 @@ struct AppSizeData: Codable { let bundle_id: String let builds: [String: BuildData] - func write(to filePath: String? = nil) async throws -> URL { + func write(to url: URL? = nil) async throws -> URL { let data = try JSONEncoder().encode(self) + let url = url ?? FileManager.default + .temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("json") - let url: URL - if let filePath { - url = URL(fileURLWithPath: filePath) - } else { - url = FileManager.default - .temporaryDirectory - .appendingPathComponent(UUID().uuidString) - .appendingPathExtension("json") - } try data.write(to: url) return url } diff --git a/Sources/History/Export/ExportHistoryCommand.swift b/Sources/History/Export/ExportHistoryCommand.swift new file mode 100644 index 0000000..a299bc0 --- /dev/null +++ b/Sources/History/Export/ExportHistoryCommand.swift @@ -0,0 +1,44 @@ +// +// Copyright © Marc Rollin. +// + +import AppStoreConnect +import ArgumentParser +import CommandLine +import Foundation +import Platform + +// MARK: - DataCommand + +struct ExportHistoryCommand: AsyncParsableCommand, BuildSizeHistoryFetcher { + + // MARK: Lifecycle + + init() { } + + // MARK: Internal + + static var configuration = CommandConfiguration( + commandName: "export", + abstract: "Export in-depth size metrics for a specific app." + ) + + @OptionGroup var appStoreConnectArguments: AppStoreConnectArguments + @OptionGroup var fetchOptions: FetchOptions + @OptionGroup var sizeOptions: SizeOptions + @OptionGroup var exportOptions: ExportOptions + @OptionGroup var consoleOptions: ConsoleOptions + + func run() async throws { + Console.configure(options: consoleOptions) + let history = try await fetchHistory() + let data = AppSizeData( + app: history.app, + buildsSizes: history.sizes, + includeDownloadSize: sizeOptions.includeDownloadSize, + includeInstallSize: sizeOptions.includeInstallSize + ) + let url = try await data.write(to: exportOptions.output) + Console.success("Export \(exportOptions.format.rawValue.uppercased()) file saved at: \(url.absoluteString)") + } +} diff --git a/Sources/GraphCommand/Dashboard/Dashboard.swift b/Sources/History/Graph/Dashboard/Dashboard.swift similarity index 64% rename from Sources/GraphCommand/Dashboard/Dashboard.swift rename to Sources/History/Graph/Dashboard/Dashboard.swift index 2b8c548..84039c7 100644 --- a/Sources/GraphCommand/Dashboard/Dashboard.swift +++ b/Sources/History/Graph/Dashboard/Dashboard.swift @@ -3,21 +3,22 @@ // import Charts +import Platform import SwiftUI -public struct Dashboard: View { +struct Dashboard: View { // MARK: Lifecycle - public init(model: DashboardModel) { + init(model: DashboardModel) { self.model = model } - // MARK: Public + // MARK: Internal - @State public var model: DashboardModel + @State var model: DashboardModel - public var body: some View { + var body: some View { VStack { Text(model.appName).font(.largeTitle).foregroundColor(.primary) if let downloadSizes = model.downloadSizes { @@ -31,26 +32,16 @@ public struct Dashboard: View { } .padding() .background(.background) - .colorScheme(model.darkScheme ? .dark : .light) } // MARK: Private - private static let byteCountFormatter: ByteCountFormatter = { - let formatter = ByteCountFormatter() - formatter.allowedUnits = [.useMB, .useGB] - formatter.countStyle = .file - return formatter - }() - - private func formatBytes(_ bytes: Int) -> String { - Self.byteCountFormatter.string(fromByteCount: Int64(bytes)) - } - private func chart(title: String, sizes: [DashboardModel.Size]) -> some View { Section { Chart(sizes, id: \.version) { size in - thinnedRangeBarMark(size: size) + ForEach(DashboardModel.Size.Categories.allCases) { category in + thinnedRange(size: size, name: category.name) + } universalLineMark(size: size) referenceLineMark(size: size) .symbol(Circle().strokeBorder(style: StrokeStyle(lineWidth: 2))) @@ -64,16 +55,17 @@ public struct Dashboard: View { AxisGridLine() if let size = value.as(Int.self) { - AxisValueLabel(formatBytes(size)) + AxisValueLabel(size.formattedBytes()) } } } - .frame(width: CGFloat(sizes.count * 60), height: 600) + .frame(width: CGFloat(sizes.count * 80), height: 600) } header: { Text(title) .font(.title) .foregroundColor(.primary) } + .frame(minWidth: 400) .padding() } @@ -85,14 +77,8 @@ public struct Dashboard: View { y: .value(device, reference) ) .foregroundStyle(by: .value("Type", device)) - .annotation( - position: size.thinned.lowerBound.distance(to: reference) - < reference.distance(to: size.thinned.upperBound) - ? .bottom - : .top, - spacing: 6 - ) { - Text(formatBytes(reference)) + .annotation(spacing: 6) { + Text(reference.formattedBytes()) .font(.caption.monospacedDigit().bold()) .foregroundColor(.secondary) } @@ -121,14 +107,29 @@ public struct Dashboard: View { } @ChartContentBuilder - private func thinnedRangeBarMark(size: DashboardModel.Size) -> some ChartContent { - BarMark( - x: .value("Version", size.version), - yStart: .value("Thinned Min", size.thinned.lowerBound), - yEnd: .value("Thinned Max", size.thinned.upperBound), - width: .fixed(8) - ) - .clipShape(Capsule()) - .foregroundStyle(by: .value("Type", "Thinned variants")) + private func thinnedRange(size: DashboardModel.Size, name: String) -> some ChartContent { + if let range = size.thinned[name] { + thinnedRange(size: size, range: range, name: name) + .opacity(size.reference == nil ? 1 : 0.4) + .foregroundStyle(by: .value("Type", name)) + .position(by: .value("Type", name)) + } + } + + @ChartContentBuilder + private func thinnedRange(size: DashboardModel.Size, range: ClosedRange, name: String) -> some ChartContent { + if range.count > 2 { + BarMark( + x: .value("Version", size.version), + yStart: .value("Min", range.lowerBound), + yEnd: .value("Max", range.upperBound), + width: .fixed(8) + ) + } else { + PointMark( + x: .value("Version", size.version), + y: .value("Average", Double(range.lowerBound + range.upperBound) / 2.0) + ) + } } } diff --git a/Sources/GraphCommand/Dashboard/DashboardModel+AppStoreConnect.swift b/Sources/History/Graph/Dashboard/DashboardModel+AppStoreConnect.swift similarity index 65% rename from Sources/GraphCommand/Dashboard/DashboardModel+AppStoreConnect.swift rename to Sources/History/Graph/Dashboard/DashboardModel+AppStoreConnect.swift index 23325bc..ebc9b59 100644 --- a/Sources/GraphCommand/Dashboard/DashboardModel+AppStoreConnect.swift +++ b/Sources/History/Graph/Dashboard/DashboardModel+AppStoreConnect.swift @@ -2,8 +2,9 @@ // Copyright © Marc Rollin. // -import AppStoreConnectModels +import AppStoreConnect import Foundation +import SwiftUI extension DashboardModel { @@ -12,8 +13,7 @@ extension DashboardModel { buildsSizes: [BuildSizes], includeDownloadSize: Bool, includeInstallSize: Bool, - referenceDeviceIdentifier: String?, - darkScheme: Bool + referenceDeviceIdentifier: String? ) { self.init( appName: app.name, @@ -35,8 +35,7 @@ extension DashboardModel { ) } : nil, - referenceDeviceIdentifier: referenceDeviceIdentifier, - darkScheme: darkScheme + referenceDeviceIdentifier: referenceDeviceIdentifier ) } } @@ -46,26 +45,34 @@ extension DashboardModel.Size { // MARK: Lifecycle fileprivate init(buildSizes: BuildSizes, category: Category, referenceDeviceIdentifier: String? = nil) { - let keyPath: KeyPath - - switch category { - case .download: - keyPath = \.downloadBytes - case .install: - keyPath = \.installBytes + let keyPath: KeyPath = switch category { + case .download: \.downloadBytes + case .install: \.installBytes } var sizeByDevice = buildSizes.fileSizes.reduce(into: [String: Int]()) { result, size in result[size.deviceModel] = size[keyPath: keyPath] } let universal = sizeByDevice.removeValue(forKey: DashboardModel.universalIdentifier) - let thinnedSizes = sizeByDevice.values + + let prefixes = DashboardModel.Size.Categories.allCases.map(\.name) + var thinned: [String: ClosedRange] = prefixes.reduce(into: [:]) { acc, prefix in + let filteredSizes = sizeByDevice.filter { $0.key.hasPrefix(prefix) }.map(\.value) + if let minSize = filteredSizes.min(), let maxSize = filteredSizes.max() { + acc[prefix] = minSize...maxSize + } + } + + let otherSizes = sizeByDevice.filter { key, _ in !prefixes.contains { key.hasPrefix($0) } }.map(\.value) + if let minOther = otherSizes.min(), let maxOther = otherSizes.max() { + thinned[DashboardModel.Size.Categories.others.name] = minOther...maxOther + } self.init( version: buildSizes.version?.version ?? buildSizes.build.version, universal: universal, reference: referenceDeviceIdentifier != nil ? sizeByDevice[referenceDeviceIdentifier!] : nil, - thinned: (thinnedSizes.min() ?? 0)...(thinnedSizes.max() ?? 0) + thinned: thinned ) } diff --git a/Sources/GraphCommand/Dashboard/DashboardModel.swift b/Sources/History/Graph/Dashboard/DashboardModel.swift similarity index 51% rename from Sources/GraphCommand/Dashboard/DashboardModel.swift rename to Sources/History/Graph/Dashboard/DashboardModel.swift index 9516414..a28e7b4 100644 --- a/Sources/GraphCommand/Dashboard/DashboardModel.swift +++ b/Sources/History/Graph/Dashboard/DashboardModel.swift @@ -2,49 +2,66 @@ // Copyright © Marc Rollin. // +import CommandLine import Foundation +import SwiftUI -public struct DashboardModel { +struct DashboardModel { // MARK: Lifecycle - public init( + init( appName: String, downloadSizes: [Size]?, installSizes: [Size]?, - referenceDeviceIdentifier: String?, - darkScheme: Bool + referenceDeviceIdentifier: String? ) { self.appName = appName self.downloadSizes = downloadSizes self.installSizes = installSizes self.referenceDeviceIdentifier = referenceDeviceIdentifier - self.darkScheme = darkScheme } - // MARK: Public + // MARK: Internal - public struct Size { - let version: String - let universal: Int? - let reference: Int? - let thinned: ClosedRange + struct Size { + + // MARK: Lifecycle - public init(version: String, universal: Int? = nil, reference: Int? = nil, thinned: ClosedRange) { + init(version: String, universal: Int? = nil, reference: Int? = nil, thinned: [String: ClosedRange]) { self.version = version self.universal = universal self.reference = reference self.thinned = thinned } - } - public static let universalIdentifier = "Universal" + // MARK: Internal - // MARK: Internal + enum Categories: String, CaseIterable, Identifiable { + case watch = "Watch" + case iPad, iPhone + case others = "Others" + + public var id: String { + rawValue + } + + public var name: String { + rawValue + } + } + + let version: String + let universal: Int? + let reference: Int? + let thinned: [String: ClosedRange] + + } + + static let universalIdentifier = "Universal" let appName: String let downloadSizes: [Size]? let installSizes: [Size]? let referenceDeviceIdentifier: String? - let darkScheme: Bool } diff --git a/Sources/History/Graph/GraphHistoryCommand.swift b/Sources/History/Graph/GraphHistoryCommand.swift new file mode 100644 index 0000000..c4cb466 --- /dev/null +++ b/Sources/History/Graph/GraphHistoryCommand.swift @@ -0,0 +1,53 @@ +// +// Copyright © Marc Rollin. +// + +import ArgumentParser +import CommandLine +import Foundation +import Platform +import SwiftUI + +struct GraphHistoryCommand: AsyncParsableCommand, BuildSizeHistoryFetcher { + + // MARK: Lifecycle + + init() { } + + // MARK: Internal + + static var configuration = CommandConfiguration( + commandName: "graph", + abstract: "Create a graph of sizes by version or build for a specific app." + ) + + @OptionGroup var appStoreConnectArguments: AppStoreConnectArguments + @OptionGroup var fetchOptions: FetchOptions + @OptionGroup var sizeOptions: SizeOptions + @OptionGroup var exportOptions: ExportOptions + @OptionGroup var consoleOptions: ConsoleOptions + @OptionGroup var themeOptions: ThemeOptions + + @Option(help: "The reference device to highlight in the charts.") + var referenceDeviceIdentifier = "iPhone12,1" + + func run() async throws { + Console.configure(options: consoleOptions) + let history = try await fetchHistory() + let dashboard = Dashboard( + model: .init( + app: history.app, + buildsSizes: history.sizes, + includeDownloadSize: sizeOptions.includeDownloadSize, + includeInstallSize: sizeOptions.includeInstallSize, + referenceDeviceIdentifier: referenceDeviceIdentifier + ) + ).colorScheme(themeOptions.colorScheme) + + let url: URL = switch exportOptions.format { + case .pdf: try await dashboard.savePDF(to: exportOptions.output) + case .png: try await dashboard.saveImage(to: exportOptions.output) + } + Console.success("Graph \(exportOptions.format.rawValue.uppercased()) file saved at: \(url.absoluteString)") + } +} diff --git a/Sources/History/HistoryCommand.swift b/Sources/History/HistoryCommand.swift new file mode 100644 index 0000000..674a771 --- /dev/null +++ b/Sources/History/HistoryCommand.swift @@ -0,0 +1,17 @@ +// +// Copyright © Marc Rollin. +// + +import ArgumentParser +import Foundation + +public struct HistoryCommand: AsyncParsableCommand { + + public init() { } + + public static var configuration = CommandConfiguration( + commandName: "history", + abstract: "Generate app size history and metrics via App Store Connect.", + subcommands: [ExportHistoryCommand.self, GraphHistoryCommand.self] + ) +} diff --git a/Sources/SteelyardCommand/SteelyardCommand.swift b/Sources/Steelyard/SteelyardCommand.swift similarity index 53% rename from Sources/SteelyardCommand/SteelyardCommand.swift rename to Sources/Steelyard/SteelyardCommand.swift index 9bc27ab..db76794 100644 --- a/Sources/SteelyardCommand/SteelyardCommand.swift +++ b/Sources/Steelyard/SteelyardCommand.swift @@ -2,17 +2,17 @@ // Copyright © Marc Rollin. // +import Archive import ArgumentParser -import DataCommand import Foundation -import GraphCommand +import History @main struct SteelyardCommand: AsyncParsableCommand { static var configuration = CommandConfiguration( commandName: "steelyard", - abstract: "Generate insightful App Size Graphs & JSON Metrics using App Store Connect API.", - subcommands: [GraphCommand.self, DataCommand.self] + abstract: "Analyze and optimize your Apple app's build sizes.", + subcommands: [HistoryCommand.self, ArchiveCommand.self] ) }