-
-
Notifications
You must be signed in to change notification settings - Fork 4
/
Copy pathUploadScreenshot.swift
289 lines (271 loc) · 10.8 KB
/
UploadScreenshot.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
import AppStoreAPI
import AppStoreConnect
import ArgumentParser
import Crypto
import Foundation
import Utilities
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
@main struct UploadScreenshot: AsyncParsableCommand {
enum Platform: String, ExpressibleByArgument {
case iOS
case macOS
case tvOS
case visionOS
}
enum ScreenshotDisplayType: String, ExpressibleByArgument {
case appIphone67
case appIphone61
case appIphone65
case appIphone58
case appIphone55
case appIphone47
case appIphone40
case appIphone35
case appIpadPro3gen129
case appIpadPro3gen11
case appIpadPro129
case appIpad105
case appIpad97
case appDesktop
case appWatchUltra
case appWatchSeries7
case appWatchSeries4
case appWatchSeries3
case appAppleTv
case appAppleVisionPro
case imessageAppIphone67
case imessageAppIphone61
case imessageAppIphone65
case imessageAppIphone58
case imessageAppIphone55
case imessageAppIphone47
case imessageAppIphone40
case imessageAppIpadPro3gen129
case imessageAppIpadPro3gen11
case imessageAppIpadPro129
case imessageAppIpad105
case imessageAppIpad97
}
@Option var bundleID: String
@Option var platform: Platform
@Option var version: String
@Option var locale: String
@Option var screenshotType: ScreenshotDisplayType
@Option(
name: [.customLong("file"), .customShort("f")],
completion: .file(),
transform: URL.init(fileURLWithPath:)
)
var screenshotFileURL: URL
mutating func run() async throws {
// 0. Open screenshot file for reading
let screenshotFile = try Data(contentsOf: screenshotFileURL)
var md5 = Insecure.MD5()
md5.update(data: screenshotFile)
let digest = md5.finalize()
let screenshotFileChecksum = digest.description
// 1. Create the App Store Connect client
let client = try AppStoreConnectClient(authenticator: EnvAuthenticator())
// 2. Look up the app by bundle ID.
let app = try await client
.send(
Resources.v1.apps.get(
filterBundleID: [bundleID]
)
)
.data.first!
// 3. Look up the version version by platform and version number.
let version = try await client
.send(
Resources.v1.apps.id(app.id).appStoreVersions
.get(filterPlatform: [.init(platform)], filterVersionString: [version])
)
.data.first!
// 4. Get all localizations for the version and look for the requested locale.
let localizations: [AppStoreVersionLocalization]
let existingLocalizations =
try await client.send(Resources.v1.appStoreVersions.id(version.id).appStoreVersionLocalizations.get()).data
.filter { $0.attributes?.locale == locale }
// 4a. If the requested localization does not exist, create it. Localized attributes are copied from the primary locale so there's no need to worry about them here.
if existingLocalizations.isEmpty {
let newLocalization = try await client.send(
Resources.v1.appStoreVersionLocalizations.post(
.init(
data: .init(
attributes: .init(locale: locale),
relationships: .init(appStoreVersion: .init(data: .init(id: version.id)))
)
)
)
)
localizations = [newLocalization.data]
} else {
localizations = existingLocalizations
}
for localization in localizations {
// 5. Get all available app screenshot sets from the localization. If a screenshot set for the desired screenshot type already exists, use it. Otherwise, make a new one.
var screenshotSets: [AppScreenshotSet] = []
if let related = localization.relationships?.appScreenshotSets?.links?.related {
let screenshotSetsResponse: AppScreenshotSetsResponse = try await client.send(.get(related))
screenshotSets.append(
contentsOf: screenshotSetsResponse.data.filter {
$0.attributes?.screenshotDisplayType == .init(screenshotType)
}
)
}
if screenshotSets.isEmpty {
let newScreenshotSet = try await client.send(
Resources.v1.appScreenshotSets.post(
.init(
data: .init(
attributes: .init(screenshotDisplayType: .init(screenshotType)),
relationships: .init(
appStoreVersionLocalization: .init(data: .init(id: localization.id))
)
)
)
)
)
screenshotSets.append(newScreenshotSet.data)
}
for screenshotSet in screenshotSets {
// 6. Reserve an app screenshot in the selected app screenshot set. Tell the API to create a screenshot before uploading the screenshot data.
print("Reserving space for a new app screenshot.")
let screenshot =
try await client.send(
Resources.v1.appScreenshots.post(
.init(
data: .init(
attributes: .init(
fileSize: screenshotFile.count,
fileName: screenshotFileURL.lastPathComponent
),
relationships: .init(appScreenshotSet: .init(data: .init(id: screenshotSet.id)))
)
)
)
)
.data
guard let uploadOperations = screenshot.attributes?.uploadOperations else { continue }
print("Uploading \(uploadOperations.count) screenshot components.")
// 7. Upload each part according to the returned upload operations. The reservation returned uploadOperations, which instructs us how to split the asset into parts. Upload each part individually.
// Note: To speed up the process, upload multiple parts asynchronously if you have the bandwidth.
try await withThrowingTaskGroup(of: Void.self) { group in
for operation in uploadOperations {
group.addTask {
try await client.upload(operation: operation, from: screenshotFile)
}
}
try await group.waitForAll()
}
// 8. Commit the reservation and provide a checksum. Committing tells App Store Connect the script is finished uploading parts. App Store Connect uses the checksum to ensure the parts were uploaded successfully.
print("Commit the reservation.")
_ = try await client.send(
Resources.v1.appScreenshots.id(screenshot.id)
.patch(
.init(
data: .init(
id: screenshot.id,
attributes: .init(sourceFileChecksum: screenshotFileChecksum, isUploaded: true)
)
)
)
)
// 9. Report success to the caller.
print(
"""
App Screenshot successfully uploaded to:
\(screenshot.links?.this?.absoluteString ?? "<no screenshot url>")
You can verify success in App Store Connect or using the API.
"""
)
}
}
}
}
extension Resources.V1.Apps.WithID.AppStoreVersions.FilterPlatform {
init(_ platform: UploadScreenshot.Platform) {
switch platform {
case .iOS:
self = .iOS
case .macOS:
self = .macOS
case .tvOS:
self = .tvOS
case .visionOS:
self = .visionOS
}
}
}
extension ScreenshotDisplayType {
init(_ screenshotType: UploadScreenshot.ScreenshotDisplayType) {
switch screenshotType {
case .appIphone67:
self = .appIphone67
case .appIphone61:
self = .appIphone61
case .appIphone65:
self = .appIphone65
case .appIphone58:
self = .appIphone58
case .appIphone55:
self = .appIphone55
case .appIphone47:
self = .appIphone47
case .appIphone40:
self = .appIphone40
case .appIphone35:
self = .appIphone35
case .appIpadPro3gen129:
self = .appIpadPro3gen129
case .appIpadPro3gen11:
self = .appIpadPro3gen11
case .appIpadPro129:
self = .appIpadPro129
case .appIpad105:
self = .appIpad105
case .appIpad97:
self = .appIpad97
case .appDesktop:
self = .appDesktop
case .appWatchUltra:
self = .appWatchUltra
case .appWatchSeries7:
self = .appWatchSeries7
case .appWatchSeries4:
self = .appWatchSeries4
case .appWatchSeries3:
self = .appWatchSeries3
case .appAppleTv:
self = .appAppleTv
case .appAppleVisionPro:
self = .appAppleVisionPro
case .imessageAppIphone67:
self = .imessageAppIphone67
case .imessageAppIphone61:
self = .imessageAppIphone61
case .imessageAppIphone65:
self = .imessageAppIphone65
case .imessageAppIphone58:
self = .imessageAppIphone58
case .imessageAppIphone55:
self = .imessageAppIphone55
case .imessageAppIphone47:
self = .imessageAppIphone47
case .imessageAppIphone40:
self = .imessageAppIphone40
case .imessageAppIpadPro3gen129:
self = .imessageAppIpadPro3gen129
case .imessageAppIpadPro3gen11:
self = .imessageAppIpadPro3gen11
case .imessageAppIpadPro129:
self = .imessageAppIpadPro129
case .imessageAppIpad105:
self = .imessageAppIpad105
case .imessageAppIpad97:
self = .imessageAppIpad97
}
}
}