From 79f5c8b8626d7300977a7c017dac798899fc10b9 Mon Sep 17 00:00:00 2001 From: Priyambada Roul Date: Sat, 21 Jun 2025 23:47:11 +0530 Subject: [PATCH 1/6] Json Progress File --- .../Swiftly/JsonFileProgressReporter.swift | 58 +++++++++++++++++++ .../SwiftlyCore/FileManager+FilePath.swift | 16 +++++ .../JsonFileProgressReporterTests.swift | 16 +++++ 3 files changed, 90 insertions(+) create mode 100644 Sources/Swiftly/JsonFileProgressReporter.swift diff --git a/Sources/Swiftly/JsonFileProgressReporter.swift b/Sources/Swiftly/JsonFileProgressReporter.swift new file mode 100644 index 00000000..dd06fad8 --- /dev/null +++ b/Sources/Swiftly/JsonFileProgressReporter.swift @@ -0,0 +1,58 @@ +import Foundation +import SwiftlyCore +import SystemPackage +import TSCUtility + +enum ProgressInfo: Codable { + case step(timestamp: Date, percent: Int, text: String) + case complete(success: Bool) +} + +struct JsonFileProgressReporter: ProgressAnimationProtocol { + let filePath: FilePath + private let encoder: JSONEncoder + + init(filePath: FilePath, encoder: JSONEncoder = JSONEncoder()) { + self.filePath = filePath + self.encoder = encoder + } + + private func writeProgress(_ progress: ProgressInfo) { + let jsonData = try? self.encoder.encode(progress) + guard let jsonData = jsonData, let jsonString = String(data: jsonData, encoding: .utf8) + else { + print("Failed to encode progress entry to JSON") + return + } + + let jsonLine = jsonString + "\n" + + do { + try jsonLine.append(to: self.filePath) + } catch { + print("Failed to write progress entry to \(self.filePath): \(error)") + } + } + + func update(step: Int, total: Int, text: String) { + assert(step <= total) + self.writeProgress( + ProgressInfo.step( + timestamp: Date(), + percent: Int(Double(step) / Double(total) * 100), + text: text + )) + } + + func complete(success: Bool) { + self.writeProgress(ProgressInfo.complete(success: success)) + } + + func clear() { + do { + try FileManager.default.removeItem(atPath: self.filePath.string) + } catch { + print("Failed to clear progress file at \(self.filePath): \(error)") + } + } +} diff --git a/Sources/SwiftlyCore/FileManager+FilePath.swift b/Sources/SwiftlyCore/FileManager+FilePath.swift index abac3487..fc04dafb 100644 --- a/Sources/SwiftlyCore/FileManager+FilePath.swift +++ b/Sources/SwiftlyCore/FileManager+FilePath.swift @@ -190,6 +190,22 @@ extension String { try self.write(to: URL(fileURLWithPath: path.string), atomically: atomically, encoding: enc) } + public func append(to path: FilePath, encoding enc: String.Encoding = .utf8) throws { + if !FileManager.default.fileExists(atPath: path.string) { + try self.write(to: path, atomically: true, encoding: enc) + return + } + + let fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: path.string)) + defer { fileHandle.closeFile() } + fileHandle.seekToEndOfFile() + if let data = self.data(using: enc) { + fileHandle.write(data) + } else { + throw SwiftlyError(message: "Failed to convert string to data with encoding \(enc)") + } + } + public init(contentsOf path: FilePath, encoding enc: String.Encoding = .utf8) throws { try self.init(contentsOf: URL(fileURLWithPath: path.string), encoding: enc) } diff --git a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift index 8fe9105d..fa7f501c 100644 --- a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift +++ b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift @@ -1,4 +1,6 @@ import Foundation +@testable import Swiftly +@testable import SwiftlyCore import SystemPackage import Testing @@ -100,6 +102,20 @@ import Testing try FileManager.default.removeItem(atPath: tempFile.string) } + @Test("Test clear method removes file") + func testClearRemovesFile() throws { + let tempFile = fs.mktemp(ext: ".json") + let reporter = JsonFileProgressReporter(filePath: tempFile) + + reporter.update(step: 1, total: 2, text: "Test") + + #expect(FileManager.default.fileExists(atPath: tempFile.string)) + + reporter.clear() + + #expect(!FileManager.default.fileExists(atPath: tempFile.string)) + } + @Test("Test multiple progress updates create multiple lines") func testMultipleUpdatesCreateMultipleLines() async throws { let tempFile = fs.mktemp(ext: ".json") From 3e9e091d9056940012dea291ff5c13c3336b65fa Mon Sep 17 00:00:00 2001 From: Priyambada Roul Date: Tue, 24 Jun 2025 00:02:03 +0530 Subject: [PATCH 2/6] Improve JSON progress file handling and error reporting --- .../Swiftly/JsonFileProgressReporter.swift | 36 ++++++++++--------- .../SwiftlyCore/FileManager+FilePath.swift | 9 ----- .../JsonFileProgressReporterTests.swift | 15 ++++---- 3 files changed, 28 insertions(+), 32 deletions(-) diff --git a/Sources/Swiftly/JsonFileProgressReporter.swift b/Sources/Swiftly/JsonFileProgressReporter.swift index dd06fad8..3273a523 100644 --- a/Sources/Swiftly/JsonFileProgressReporter.swift +++ b/Sources/Swiftly/JsonFileProgressReporter.swift @@ -11,27 +11,30 @@ enum ProgressInfo: Codable { struct JsonFileProgressReporter: ProgressAnimationProtocol { let filePath: FilePath private let encoder: JSONEncoder + private let ctx: SwiftlyCoreContext + private let fileHandle: FileHandle - init(filePath: FilePath, encoder: JSONEncoder = JSONEncoder()) { + init(_ ctx: SwiftlyCoreContext, filePath: FilePath, encoder: JSONEncoder = JSONEncoder()) throws + { + self.ctx = ctx self.filePath = filePath self.encoder = encoder + self.fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: filePath.string)) } private func writeProgress(_ progress: ProgressInfo) { let jsonData = try? self.encoder.encode(progress) - guard let jsonData = jsonData, let jsonString = String(data: jsonData, encoding: .utf8) - else { - print("Failed to encode progress entry to JSON") + guard let jsonData = jsonData else { + Task { [ctx = self.ctx] in + await ctx.message("Failed to encode progress entry to JSON") + } return } - let jsonLine = jsonString + "\n" - - do { - try jsonLine.append(to: self.filePath) - } catch { - print("Failed to write progress entry to \(self.filePath): \(error)") - } + self.fileHandle.seekToEndOfFile() + self.fileHandle.write(jsonData) + self.fileHandle.write("\n".data(using: .utf8) ?? Data()) + self.fileHandle.synchronizeFile() } func update(step: Int, total: Int, text: String) { @@ -49,10 +52,11 @@ struct JsonFileProgressReporter: ProgressAnimationProtocol { } func clear() { - do { - try FileManager.default.removeItem(atPath: self.filePath.string) - } catch { - print("Failed to clear progress file at \(self.filePath): \(error)") - } + self.fileHandle.truncateFile(atOffset: 0) + self.fileHandle.synchronizeFile() + } + + func close() throws { + try self.fileHandle.close() } } diff --git a/Sources/SwiftlyCore/FileManager+FilePath.swift b/Sources/SwiftlyCore/FileManager+FilePath.swift index fc04dafb..ac40e297 100644 --- a/Sources/SwiftlyCore/FileManager+FilePath.swift +++ b/Sources/SwiftlyCore/FileManager+FilePath.swift @@ -195,15 +195,6 @@ extension String { try self.write(to: path, atomically: true, encoding: enc) return } - - let fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: path.string)) - defer { fileHandle.closeFile() } - fileHandle.seekToEndOfFile() - if let data = self.data(using: enc) { - fileHandle.write(data) - } else { - throw SwiftlyError(message: "Failed to convert string to data with encoding \(enc)") - } } public init(contentsOf path: FilePath, encoding enc: String.Encoding = .utf8) throws { diff --git a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift index fa7f501c..e4a18278 100644 --- a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift +++ b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift @@ -1,6 +1,4 @@ import Foundation -@testable import Swiftly -@testable import SwiftlyCore import SystemPackage import Testing @@ -102,18 +100,21 @@ import Testing try FileManager.default.removeItem(atPath: tempFile.string) } - @Test("Test clear method removes file") - func testClearRemovesFile() throws { + @Test("Test clear method truncates the file") + func testClearTruncatesFile() async throws { let tempFile = fs.mktemp(ext: ".json") - let reporter = JsonFileProgressReporter(filePath: tempFile) + try await fs.create(.mode(Int(0o644)), file: tempFile) + defer { try? FileManager.default.removeItem(atPath: tempFile.string) } + let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile) + defer { try? reporter.close() } reporter.update(step: 1, total: 2, text: "Test") - #expect(FileManager.default.fileExists(atPath: tempFile.string)) + #expect(try String(contentsOf: tempFile).lengthOfBytes(using: String.Encoding.utf8) > 0) reporter.clear() - #expect(!FileManager.default.fileExists(atPath: tempFile.string)) + #expect(try String(contentsOf: tempFile).lengthOfBytes(using: String.Encoding.utf8) == 0) } @Test("Test multiple progress updates create multiple lines") From 36f02ca112fc5dec3198c496c5f492e5f75676b5 Mon Sep 17 00:00:00 2001 From: Priyambada Roul Date: Tue, 24 Jun 2025 21:01:25 +0530 Subject: [PATCH 3/6] Refactor progress file parsing and improve test validation --- Sources/Swiftly/JsonFileProgressReporter.swift | 10 +++++----- Sources/SwiftlyCore/FileManager+FilePath.swift | 7 ------- .../JsonFileProgressReporterTests.swift | 17 ----------------- 3 files changed, 5 insertions(+), 29 deletions(-) diff --git a/Sources/Swiftly/JsonFileProgressReporter.swift b/Sources/Swiftly/JsonFileProgressReporter.swift index 3273a523..76fafc8b 100644 --- a/Sources/Swiftly/JsonFileProgressReporter.swift +++ b/Sources/Swiftly/JsonFileProgressReporter.swift @@ -31,14 +31,15 @@ struct JsonFileProgressReporter: ProgressAnimationProtocol { return } - self.fileHandle.seekToEndOfFile() self.fileHandle.write(jsonData) self.fileHandle.write("\n".data(using: .utf8) ?? Data()) - self.fileHandle.synchronizeFile() + try? self.fileHandle.synchronize() } func update(step: Int, total: Int, text: String) { - assert(step <= total) + guard total > 0 && step <= total else { + return + } self.writeProgress( ProgressInfo.step( timestamp: Date(), @@ -52,8 +53,7 @@ struct JsonFileProgressReporter: ProgressAnimationProtocol { } func clear() { - self.fileHandle.truncateFile(atOffset: 0) - self.fileHandle.synchronizeFile() + // not implemented for JSON file reporter } func close() throws { diff --git a/Sources/SwiftlyCore/FileManager+FilePath.swift b/Sources/SwiftlyCore/FileManager+FilePath.swift index ac40e297..abac3487 100644 --- a/Sources/SwiftlyCore/FileManager+FilePath.swift +++ b/Sources/SwiftlyCore/FileManager+FilePath.swift @@ -190,13 +190,6 @@ extension String { try self.write(to: URL(fileURLWithPath: path.string), atomically: atomically, encoding: enc) } - public func append(to path: FilePath, encoding enc: String.Encoding = .utf8) throws { - if !FileManager.default.fileExists(atPath: path.string) { - try self.write(to: path, atomically: true, encoding: enc) - return - } - } - public init(contentsOf path: FilePath, encoding enc: String.Encoding = .utf8) throws { try self.init(contentsOf: URL(fileURLWithPath: path.string), encoding: enc) } diff --git a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift index e4a18278..8fe9105d 100644 --- a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift +++ b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift @@ -100,23 +100,6 @@ import Testing try FileManager.default.removeItem(atPath: tempFile.string) } - @Test("Test clear method truncates the file") - func testClearTruncatesFile() async throws { - let tempFile = fs.mktemp(ext: ".json") - try await fs.create(.mode(Int(0o644)), file: tempFile) - defer { try? FileManager.default.removeItem(atPath: tempFile.string) } - let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile) - defer { try? reporter.close() } - - reporter.update(step: 1, total: 2, text: "Test") - - #expect(try String(contentsOf: tempFile).lengthOfBytes(using: String.Encoding.utf8) > 0) - - reporter.clear() - - #expect(try String(contentsOf: tempFile).lengthOfBytes(using: String.Encoding.utf8) == 0) - } - @Test("Test multiple progress updates create multiple lines") func testMultipleUpdatesCreateMultipleLines() async throws { let tempFile = fs.mktemp(ext: ".json") From 6d83aef004ba483e6ec0bcb3e8610779233793a3 Mon Sep 17 00:00:00 2001 From: Priyambada Roul Date: Mon, 30 Jun 2025 14:59:39 +0530 Subject: [PATCH 4/6] Refactored JSON Output for Install Command --- Sources/Swiftly/Install.swift | 30 ++++++--- .../Swiftly/JsonFileProgressReporter.swift | 62 ------------------- Sources/Swiftly/OutputSchema.swift | 25 ++++++++ 3 files changed, 46 insertions(+), 71 deletions(-) delete mode 100644 Sources/Swiftly/JsonFileProgressReporter.swift diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index 516fd970..bd6b0312 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -81,14 +81,17 @@ struct Install: SwiftlyCommand { )) var progressFile: FilePath? + @Option(name: .long, help: "Output format (text, json)") + var format: SwiftlyCore.OutputFormat = .text + @OptionGroup var root: GlobalOptions private enum CodingKeys: String, CodingKey { - case version, use, verify, postInstallFile, root, progressFile + case version, use, verify, postInstallFile, root, progressFile, format } mutating func run() async throws { - try await self.run(Swiftly.createDefaultContext()) + try await self.run(Swiftly.createDefaultContext(format: self.format)) } private func swiftlyHomeDir(_ ctx: SwiftlyCoreContext) -> FilePath { @@ -266,7 +269,10 @@ struct Install: SwiftlyCommand { progressFile: FilePath? = nil ) async throws -> (postInstall: String?, pathChanged: Bool) { guard !config.installedToolchains.contains(version) else { - await ctx.message("\(version) is already installed.") + let installInfo = InstallInfo( + version: version, alreadyInstalled: true + ) + try await ctx.output(installInfo) return (nil, false) } @@ -312,16 +318,18 @@ struct Install: SwiftlyCommand { } } - let animation: ProgressReporterProtocol = + let animation: ProgressReporterProtocol? = if let progressFile { try JsonFileProgressReporter(ctx, filePath: progressFile) + } else if ctx.format == .json { + nil } else { ConsoleProgressReporter(stream: stdoutStream, header: "Downloading \(version)") } defer { - try? animation.close() + try? animation?.close() } var lastUpdate = Date() @@ -351,7 +359,7 @@ struct Install: SwiftlyCommand { lastUpdate = Date() do { - try await animation.update( + try await animation?.update( step: progress.receivedBytes, total: progress.totalBytes!, text: @@ -368,10 +376,10 @@ struct Install: SwiftlyCommand { throw SwiftlyError( message: "\(version) does not exist at URL \(notFound.url), exiting") } catch { - try? await animation.complete(success: false) + try? await animation?.complete(success: false) throw error } - try await animation.complete(success: true) + try await animation?.complete(success: true) if verifySignature { try await Swiftly.currentPlatform.verifyToolchainSignature( @@ -427,7 +435,11 @@ struct Install: SwiftlyCommand { return (pathChanged, config) } config = newConfig - await ctx.message("\(version) installed successfully!") + let installInfo = InstallInfo( + version: version, + alreadyInstalled: false + ) + try await ctx.output(installInfo) return (postInstallScript, pathChanged) } } diff --git a/Sources/Swiftly/JsonFileProgressReporter.swift b/Sources/Swiftly/JsonFileProgressReporter.swift deleted file mode 100644 index 76fafc8b..00000000 --- a/Sources/Swiftly/JsonFileProgressReporter.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation -import SwiftlyCore -import SystemPackage -import TSCUtility - -enum ProgressInfo: Codable { - case step(timestamp: Date, percent: Int, text: String) - case complete(success: Bool) -} - -struct JsonFileProgressReporter: ProgressAnimationProtocol { - let filePath: FilePath - private let encoder: JSONEncoder - private let ctx: SwiftlyCoreContext - private let fileHandle: FileHandle - - init(_ ctx: SwiftlyCoreContext, filePath: FilePath, encoder: JSONEncoder = JSONEncoder()) throws - { - self.ctx = ctx - self.filePath = filePath - self.encoder = encoder - self.fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: filePath.string)) - } - - private func writeProgress(_ progress: ProgressInfo) { - let jsonData = try? self.encoder.encode(progress) - guard let jsonData = jsonData else { - Task { [ctx = self.ctx] in - await ctx.message("Failed to encode progress entry to JSON") - } - return - } - - self.fileHandle.write(jsonData) - self.fileHandle.write("\n".data(using: .utf8) ?? Data()) - try? self.fileHandle.synchronize() - } - - func update(step: Int, total: Int, text: String) { - guard total > 0 && step <= total else { - return - } - self.writeProgress( - ProgressInfo.step( - timestamp: Date(), - percent: Int(Double(step) / Double(total) * 100), - text: text - )) - } - - func complete(success: Bool) { - self.writeProgress(ProgressInfo.complete(success: success)) - } - - func clear() { - // not implemented for JSON file reporter - } - - func close() throws { - try self.fileHandle.close() - } -} diff --git a/Sources/Swiftly/OutputSchema.swift b/Sources/Swiftly/OutputSchema.swift index 3664914a..6c971ed2 100644 --- a/Sources/Swiftly/OutputSchema.swift +++ b/Sources/Swiftly/OutputSchema.swift @@ -339,3 +339,28 @@ struct InstalledToolchainsListInfo: OutputData { return lines.joined(separator: "\n") } } + +struct InstallInfo: OutputData { + let version: ToolchainVersion + let alreadyInstalled: Bool + + init(version: ToolchainVersion, alreadyInstalled: Bool) { + self.version = version + self.alreadyInstalled = alreadyInstalled + } + + var description: String { + "\(self.version) is \(self.alreadyInstalled ? "already installed" : "installed successfully!")" + } + + private enum CodingKeys: String, CodingKey { + case version + case alreadyInstalled + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.version.name, forKey: .version) + try container.encode(self.alreadyInstalled, forKey: .alreadyInstalled) + } +} From 176e12398b4ea2f2f3f08f28057cb1d6f1e1a9b2 Mon Sep 17 00:00:00 2001 From: Priyambada Roul Date: Mon, 7 Jul 2025 16:51:28 +0530 Subject: [PATCH 5/6] Added Test --- Sources/Swiftly/Install.swift | 2 +- Tests/SwiftlyTests/InstallTests.swift | 34 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index bd6b0312..eb4fecf1 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -325,7 +325,7 @@ struct Install: SwiftlyCommand { } else if ctx.format == .json { nil } else { - ConsoleProgressReporter(stream: stdoutStream, header: "Downloading \(version)") + ConsoleProgressReporter(stream: stderrStream, header: "Downloading \(version)") } defer { diff --git a/Tests/SwiftlyTests/InstallTests.swift b/Tests/SwiftlyTests/InstallTests.swift index 590d9b0a..4fd6d914 100644 --- a/Tests/SwiftlyTests/InstallTests.swift +++ b/Tests/SwiftlyTests/InstallTests.swift @@ -389,4 +389,38 @@ import Testing } } #endif + + /// Tests that `install` command with JSON format outputs correctly structured JSON. + @Test(.testHomeMockedToolchain()) func installJsonFormat() async throws { + let output = try await SwiftlyTests.runWithMockedIO( + Install.self, ["install", "5.7.0", "--post-install-file=\(fs.mktemp())", "--format", "json"], format: .json + ) + + let installInfo = try JSONDecoder().decode( + InstallInfo.self, + from: output[0].data(using: .utf8)! + ) + + #expect(installInfo.version.name == "5.7.0") + #expect(installInfo.alreadyInstalled == false) + } + + /// Tests that `install` command with JSON format correctly indicates when toolchain is already installed. + @Test(.testHomeMockedToolchain()) func installJsonFormatAlreadyInstalled() async throws { + // First install the toolchain + try await SwiftlyTests.runCommand(Install.self, ["install", "5.7.0", "--post-install-file=\(fs.mktemp())"]) + + // Then try to install it again with JSON format + let output = try await SwiftlyTests.runWithMockedIO( + Install.self, ["install", "5.7.0", "--post-install-file=\(fs.mktemp())", "--format", "json"], format: .json + ) + + let installInfo = try JSONDecoder().decode( + InstallInfo.self, + from: output[0].data(using: .utf8)! + ) + + #expect(installInfo.version.name == "5.7.0") + #expect(installInfo.alreadyInstalled == true) + } } From 5d0f49fe2755d1256af99f5e72d81b4458288245 Mon Sep 17 00:00:00 2001 From: Priyambada Roul Date: Tue, 8 Jul 2025 19:13:09 +0530 Subject: [PATCH 6/6] updated doc --- Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md | 7 ++++++- Sources/Swiftly/Install.swift | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md index 8896f5d1..76945d5a 100644 --- a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md +++ b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md @@ -23,7 +23,7 @@ swiftly [--version] [--help] Install a new toolchain. ``` -swiftly install [] [--use] [--verify|no-verify] [--post-install-file=] [--progress-file=] [--assume-yes] [--verbose] [--version] [--help] +swiftly install [] [--use] [--verify|no-verify] [--post-install-file=] [--progress-file=] [--format=] [--assume-yes] [--verbose] [--version] [--help] ``` **version:** @@ -89,6 +89,11 @@ Each progress entry contains timestamp, progress percentage, and a descriptive m The file must be writable, else an error will be thrown. +**--format=\:** + +*Output format (text, json)* + + **--assume-yes:** *Disable confirmation prompts by assuming 'yes'* diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index eb4fecf1..11999f9a 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -323,9 +323,9 @@ struct Install: SwiftlyCommand { { try JsonFileProgressReporter(ctx, filePath: progressFile) } else if ctx.format == .json { - nil - } else { ConsoleProgressReporter(stream: stderrStream, header: "Downloading \(version)") + } else { + ConsoleProgressReporter(stream: stdoutStream, header: "Downloading \(version)") } defer {