@@ -134,10 +134,10 @@ func uncompressArchive(at sourceURL: URL, to destinationURL: URL) throws {
134134/// - downloadDirectory: The directory to store the downloaded data in.
135135/// - Throws: Exceptions when downloading, naming, uncompressing, and moving the file.
136136/// - Returns: The name of the downloaded file.
137- func downloadFile( from sourceURL: URL , to downloadDirectory: URL ) async throws -> String ? {
137+ func downloadFile( from sourceURL: URL , to downloadDirectory: URL ) async throws -> String {
138138 let ( temporaryURL, response) = try await URLSession . shared. download ( from: sourceURL)
139139
140- guard let suggestedFilename = response. suggestedFilename else { return nil }
140+ guard let suggestedFilename = response. suggestedFilename else { fatalError ( " No suggested filename from server. " ) }
141141 let isArchive = NSString ( string: suggestedFilename) . pathExtension == " zip "
142142
143143 let downloadName : String = try {
@@ -153,7 +153,7 @@ func downloadFile(from sourceURL: URL, to downloadDirectory: URL) async throws -
153153 }
154154 } ( )
155155 let downloadURL = downloadDirectory. appendingPathComponent ( downloadName, isDirectory: false )
156-
156+ // Optionally removes any existing file without causing error.
157157 try ? FileManager . default. removeItem ( at: downloadURL)
158158
159159 if isArchive {
@@ -173,119 +173,203 @@ func downloadFile(from sourceURL: URL, to downloadDirectory: URL) async throws -
173173
174174// MARK: Script Entry
175175
176- let arguments = CommandLine . arguments
177-
178- guard arguments. count == 3 else {
179- print ( " error: Invalid number of arguments. " )
180- exit ( 1 )
181- }
182-
183- /// The samples directory, i.e., $SRCROOT/Shared/Samples.
184- let samplesDirectoryURL = URL ( fileURLWithPath: arguments [ 1 ] , isDirectory: true )
185- /// The download directory, i.e., $SRCROOT/Portal Data.
186- let downloadDirectoryURL = URL ( fileURLWithPath: arguments [ 2 ] , isDirectory: true )
187-
188- // If the download directory does not exist, create it.
189- if !FileManager. default. fileExists ( atPath: downloadDirectoryURL. path) {
190- do {
191- try FileManager . default. createDirectory ( at: downloadDirectoryURL, withIntermediateDirectories: false )
192- } catch {
193- print ( " error: Error creating download directory: \( error. localizedDescription) . " )
194- exit ( 1 )
176+ /// Error thrown by the script.
177+ enum ScriptError : Error , LocalizedError {
178+ case invalidArguments
179+ case cannotCreateDirectory( String )
180+ case cannotParseDependencies( String )
181+ case downloadFailed
182+
183+ var errorDescription : String ? {
184+ switch self {
185+ case . invalidArguments:
186+ " Invalid number of arguments. "
187+ case . cannotCreateDirectory( let path) :
188+ " Cannot create directory: \( path) "
189+ case . cannotParseDependencies( let reason) :
190+ " Cannot parse dependencies: \( reason) "
191+ case . downloadFailed:
192+ " Failed to download all items. "
193+ }
195194 }
196195}
197196
198- /// Portal Items created from iterating through all metadata's "offline\_data".
199- let portalItems : Set < PortalItem > = {
197+ /// Parses all sample dependencies in the given samples directory.
198+ /// - Parameter samplesDirectoryURL: The URL to the samples directory.
199+ /// - Throws: Exceptions when unable to read or decode JSON files.
200+ /// - Returns: The portal items that represent the offline data.
201+ func parseSampleDependencies( at samplesDirectoryURL: URL ) throws -> [ PortalItem ] {
200202 do {
201203 // Finds all subdirectories under the root Samples directory.
202204 let sampleSubDirectories = try FileManager . default
203- . contentsOfDirectory ( at: samplesDirectoryURL, includingPropertiesForKeys: nil , options: [ . skipsHiddenFiles] )
205+ . contentsOfDirectory (
206+ at: samplesDirectoryURL,
207+ includingPropertiesForKeys: nil ,
208+ options: [ . skipsHiddenFiles]
209+ )
204210 . filter ( \. hasDirectoryPath)
205211 let sampleJSONs = sampleSubDirectories
206212 . map { $0. appendingPathComponent ( " README.metadata.json " , isDirectory: false ) }
207- // Omit the decoding errors from samples that don't have dependencies.
213+ // Omits the decoding errors from samples that don't have dependencies.
208214 let sampleDependencies = sampleJSONs
209215 . compactMap { try ? parseJSON ( at: $0) }
210- return Set ( sampleDependencies. lazy. flatMap ( \. offlineData) )
216+ // Some items are used by multiple samples and their IDs appear more
217+ // than once.
218+ // Removes duplicates by converting to a Set, then back to an Array.
219+ return Array ( Set ( sampleDependencies. lazy. flatMap ( \. offlineData) ) )
211220 } catch {
212- print ( " error: Error decoding Samples dependencies: \( error. localizedDescription) " )
213- exit ( 1 )
221+ throw ScriptError . cannotParseDependencies ( error. localizedDescription)
214222 }
215- } ( )
216-
217- typealias Identifier = String
218- typealias Filename = String
219- typealias DownloadedItems = [ Identifier : Filename ]
223+ }
220224
221- /// The URL to a property list that maintains records of downloaded resources.
222- let downloadedItemsURL = downloadDirectoryURL. appendingPathComponent ( " .downloaded_items.plist " , isDirectory: false )
223- let previousDownloadedItems : DownloadedItems = {
224- do {
225- let data = try Data ( contentsOf: downloadedItemsURL)
226- return try PropertyListDecoder ( ) . decode ( DownloadedItems . self, from: data)
227- } catch {
228- return [ : ]
225+ /// The main script function.
226+ func run( ) async throws { // swiftlint:disable:this function_body_length cyclomatic_complexity
227+ let arguments = CommandLine . arguments
228+ guard arguments. count == 3 else {
229+ throw ScriptError . invalidArguments
229230 }
230- } ( )
231- var downloadedItems = previousDownloadedItems
232-
233- await withTaskGroup ( of: Void . self) { group in
234- for portalItem in portalItems {
231+
232+ /// The samples directory, i.e., $SRCROOT/Shared/Samples.
233+ let samplesDirectoryURL = URL ( fileURLWithPath: arguments [ 1 ] , isDirectory: true )
234+ /// The download directory, i.e., $SRCROOT/Portal Data.
235+ let downloadDirectoryURL = URL ( fileURLWithPath: arguments [ 2 ] , isDirectory: true )
236+
237+ // If the download directory does not exist, create it.
238+ if !FileManager. default. fileExists ( atPath: downloadDirectoryURL. path) {
239+ do {
240+ try FileManager . default. createDirectory ( at: downloadDirectoryURL, withIntermediateDirectories: false )
241+ } catch {
242+ throw ScriptError . cannotCreateDirectory ( downloadDirectoryURL. path)
243+ }
244+ }
245+
246+ /// Portal Items created from iterating through all metadata's "offline_data".
247+ let portalItems = try parseSampleDependencies ( at: samplesDirectoryURL)
248+
249+ typealias Identifier = String
250+ typealias Filename = String
251+ typealias DownloadedItems = [ Identifier : Filename ]
252+
253+ /// The URL to a property list that maintains records of downloaded resources.
254+ let downloadedItemsURL = downloadDirectoryURL. appendingPathComponent ( " .downloaded_items.plist " , isDirectory: false )
255+ let previousDownloadedItems : DownloadedItems = {
256+ do {
257+ let data = try Data ( contentsOf: downloadedItemsURL)
258+ return try PropertyListDecoder ( ) . decode ( DownloadedItems . self, from: data)
259+ } catch {
260+ return [ : ]
261+ }
262+ } ( )
263+ var downloadedItems = previousDownloadedItems
264+
265+ defer {
266+ // Updates the downloaded items property list when there are changes.
267+ // It runs at the end of the function, even if there are errors.
268+ // Because defer cannot throw, errors are caught and printed.
269+ if downloadedItems != previousDownloadedItems {
270+ do {
271+ let data = try PropertyListEncoder ( ) . encode ( downloadedItems)
272+ try data. write ( to: downloadedItemsURL)
273+ } catch {
274+ print ( " error: Error recording downloaded items: \( error. localizedDescription) " )
275+ }
276+ }
277+ }
278+
279+ /// Creates a directory for the portal item in the download directory.
280+ /// - Parameter portalItem: The portal item.
281+ /// - Throws: `ScriptError.cannotCreateDirectory` when unable to create the
282+ /// directory.
283+ /// - Returns: The URL to the created directory, or `nil` if the portal item
284+ /// is already downloaded.
285+ func createDirectory( for portalItem: PortalItem ) throws -> URL ? {
235286 let destinationURL = downloadDirectoryURL. appendingPathComponent (
236287 portalItem. identifier,
237288 isDirectory: true
238289 )
239290
240291 // Checks to see if an item needs downloading.
292+ // Only skips if the item is in the plist and file exists at the
293+ // expected location.
241294 guard downloadedItems [ portalItem. identifier] == nil ||
242295 !FileManager. default. fileExists ( atPath: destinationURL. path) else {
243296 print ( " note: Item already downloaded: \( portalItem. identifier) " )
244- continue
297+ return nil
245298 }
246299
247300 // Deletes the directory when the item is not in the plist.
248301 try ? FileManager . default. removeItem ( at: destinationURL)
249302
250303 do {
251- // Creates an enclosing directory with the portal item ID as its name.
304+ // Creates an enclosing directory with the item ID as its name.
252305 try FileManager . default. createDirectory (
253306 at: destinationURL,
254307 withIntermediateDirectories: false
255308 )
309+ return destinationURL
310+ } catch {
311+ throw ScriptError . cannotCreateDirectory ( destinationURL. path)
312+ }
313+ }
314+
315+ /// Handles downloading a portal item to the given destination URL.
316+ /// - Parameters:
317+ /// - portalItem: The portal item.
318+ /// - destinationURL: The URL to the portal item download directory.
319+ /// - index: The index of the portal item in the overall list.
320+ /// - Throws: `ScriptError.downloadFailed` when unable to download the item.
321+ func handleDownload( for portalItem: PortalItem , at destinationURL: URL , index: Int ) async throws {
322+ do {
323+ let downloadName = try await downloadFile (
324+ from: portalItem. dataURL,
325+ to: destinationURL
326+ )
327+ downloadedItems. updateValue ( downloadName, forKey: portalItem. identifier)
328+ print ( " note: ( \( index + 1 ) / \( portalItems. count) ) Downloaded item: \( portalItem. identifier) " )
329+ fflush ( stdout)
256330 } catch {
257- print ( " error: Error creating download directory: \( error. localizedDescription) " )
258- exit ( 1 )
331+ print ( " error: Failed to download item \( portalItem. identifier) , \( error. localizedDescription) " )
332+ fflush ( stdout)
333+ // Deletes the directory when the item fails to download.
334+ try ? FileManager . default. removeItem ( at: destinationURL)
335+ throw ScriptError . downloadFailed
336+ }
337+ }
338+
339+ /// The maximum number of concurrent download tasks, capped at 4.
340+ let maxConcurrentTasks = min ( 4 , portalItems. count)
341+
342+ try await withThrowingTaskGroup ( of: Void . self) { group in
343+ for index in 0 ..< maxConcurrentTasks {
344+ group. addTask {
345+ let portalItem = portalItems [ index]
346+ guard let destinationURL = try createDirectory ( for: portalItem) else { return }
347+ try await handleDownload ( for: portalItem, at: destinationURL, index: index)
348+ }
259349 }
260350
261- group . addTask {
262- do {
263- guard let downloadName = try await downloadFile (
264- from : portalItem . dataURL ,
265- to : destinationURL
266- ) else { return }
267- print ( " note: Downloaded item: \( portalItem . identifier ) " )
268- fflush ( stdout )
269-
270- _ = await MainActor . run {
271- downloadedItems . updateValue ( downloadName , forKey : portalItem . identifier )
351+ var nextPortalItemIndex = maxConcurrentTasks
352+ // As each task completes, adds a new task until all portal items are
353+ // either downloaded, or an error occurs that causes the group to throw
354+ // and cancel all remaining tasks.
355+ while try await group . next ( ) != nil {
356+ if nextPortalItemIndex < portalItems . count {
357+ // Captures the next index so it doesn't go out of range.
358+ group . addTask { [ nextPortalItemIndex ] in
359+ let portalItem = portalItems [ nextPortalItemIndex ]
360+ guard let destinationURL = try createDirectory ( for : portalItem ) else { return }
361+ try await handleDownload ( for : portalItem , at : destinationURL , index : nextPortalItemIndex )
272362 }
273- } catch {
274- print ( " error: Error downloading item \( portalItem. identifier) : \( error. localizedDescription) " )
275- URLSession . shared. invalidateAndCancel ( )
276- exit ( 1 )
277363 }
364+ nextPortalItemIndex += 1
278365 }
279366 }
280367}
281368
282- // Updates the downloaded items property list record if needed.
283- if downloadedItems != previousDownloadedItems {
284- do {
285- let data = try PropertyListEncoder ( ) . encode ( downloadedItems)
286- try data. write ( to: downloadedItemsURL)
287- } catch {
288- print ( " error: Error recording downloaded items: \( error. localizedDescription) " )
289- exit ( 1 )
290- }
369+ do {
370+ try await run ( )
371+ } catch {
372+ print ( " error: \( error. localizedDescription) " )
373+ fflush ( stdout)
374+ exit ( 1 )
291375}
0 commit comments