-
Notifications
You must be signed in to change notification settings - Fork 0
Implement async download utility with progress tracking and verification #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
86c9329
821f2e1
83d9c22
ff153b3
7f7aec5
5fe6f84
2d498ed
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import Foundation | ||
|
|
||
| enum Brew { | ||
| static func caskIsOutdated(_ cask: String) -> Bool { | ||
| let command = "brew outdated --cask --greedy --quiet | grep -x \(shellQuote(cask))" | ||
| return run(command).exitCode == 0 | ||
| } | ||
|
|
||
| static func guessCask(from appName: String) -> String { | ||
| return appName.lowercased() | ||
| .replacingOccurrences(of: " ", with: "-") | ||
| .replacingOccurrences(of: ".", with: "") | ||
| } | ||
|
|
||
| static func getCaskInfo(_ cask: String) -> CaskInfo? { | ||
| let result = run("brew info --cask \(shellQuote(cask)) --json") | ||
| guard result.exitCode == 0, | ||
| let data = result.output.data(using: .utf8) else { | ||
| return nil | ||
| } | ||
|
|
||
| do { | ||
| let casks = try JSONDecoder().decode([CaskInfo].self, from: data) | ||
| return casks.first | ||
| } catch { | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| static func updateCask(_ cask: String) -> Bool { | ||
| let result = run("brew upgrade --cask \(shellQuote(cask))") | ||
| return result.exitCode == 0 | ||
| } | ||
|
|
||
| static func isBrewInstalled() -> Bool { | ||
| return run("command -v brew").exitCode == 0 | ||
| } | ||
|
|
||
| struct CaskInfo: Decodable { | ||
| let token: String | ||
| let full_name: String | ||
| let tap: String | ||
| let version: String | ||
| let installed: String? | ||
| let outdated: Bool | ||
| let homepage: String? | ||
| let url: String | ||
| let name: [String] | ||
| let desc: String? | ||
| } | ||
|
|
||
| private static func run(_ command: String) -> (exitCode: Int32, output: String) { | ||
| let task = Process() | ||
| task.launchPath = "/bin/zsh" | ||
| task.arguments = ["-lc", command] | ||
|
|
||
| let pipe = Pipe() | ||
| task.standardOutput = pipe | ||
| task.standardError = pipe | ||
|
|
||
| do { | ||
| try task.run() | ||
| task.waitUntilExit() | ||
|
|
||
| let data = pipe.fileHandleForReading.readDataToEndOfFile() | ||
| let output = String(data: data, encoding: .utf8) ?? "" | ||
|
|
||
| return (task.terminationStatus, output) | ||
| } catch { | ||
| return (-1, "") | ||
| } | ||
| } | ||
|
Comment on lines
+52
to
+72
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The private static func run(_ command: String) throws -> (exitCode: Int32, output: String) {
let task = Process()
task.launchPath = "/bin/zsh"
task.arguments = ["-lc", command]
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = pipe
try task.run()
task.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
return (task.terminationStatus, output)
} |
||
|
|
||
| private static func shellQuote(_ string: String) -> String { | ||
| return "'\(string.replacingOccurrences(of: "'", with: "'\\''"))'" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,195 @@ | ||||||||||||||||||||||||||||||
| import Foundation | ||||||||||||||||||||||||||||||
| import CryptoKit | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| actor Downloader: NSObject { | ||||||||||||||||||||||||||||||
| private var activeDownloads: [URL: DownloadTask] = [:] | ||||||||||||||||||||||||||||||
| private let session: URLSession | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| override init() { | ||||||||||||||||||||||||||||||
| let config = URLSessionConfiguration.default | ||||||||||||||||||||||||||||||
| config.timeoutIntervalForRequest = 30 | ||||||||||||||||||||||||||||||
| config.timeoutIntervalForResource = 300 | ||||||||||||||||||||||||||||||
| self.session = URLSession(configuration: config) | ||||||||||||||||||||||||||||||
| super.init() | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+8
to
+14
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To receive delegate callbacks for download progress, the
Suggested change
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| func download(_ url: URL, to destination: URL, expectedSize: Int64? = nil, expectedChecksum: String? = nil, progress: @escaping @Sendable (DownloadProgress) -> Void) async throws -> URL { | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Check if download already in progress | ||||||||||||||||||||||||||||||
| if let existingTask = activeDownloads[url] { | ||||||||||||||||||||||||||||||
| return try await existingTask.result.value | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Create new download task | ||||||||||||||||||||||||||||||
| let task = DownloadTask(url: url, destination: destination, expectedSize: expectedSize, expectedChecksum: expectedChecksum) | ||||||||||||||||||||||||||||||
| activeDownloads[url] = task | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| defer { | ||||||||||||||||||||||||||||||
| activeDownloads.removeValue(forKey: url) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| do { | ||||||||||||||||||||||||||||||
| let finalURL = try await performDownload(task: task, progress: progress) | ||||||||||||||||||||||||||||||
| task.result.resume(returning: finalURL) | ||||||||||||||||||||||||||||||
| return finalURL | ||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||
| task.result.resume(throwing: error) | ||||||||||||||||||||||||||||||
| throw error | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private func performDownload(task: DownloadTask, progress: @escaping @Sendable (DownloadProgress) -> Void) async throws -> URL { | ||||||||||||||||||||||||||||||
| // Ensure destination directory exists | ||||||||||||||||||||||||||||||
| let destinationDir = task.destination.deletingLastPathComponent() | ||||||||||||||||||||||||||||||
| try FileManager.default.createDirectory(at: destinationDir, withIntermediateDirectories: true) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Start download | ||||||||||||||||||||||||||||||
| let (tempURL, response) = try await session.download(from: task.url) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| guard let httpResponse = response as? HTTPURLResponse else { | ||||||||||||||||||||||||||||||
| throw DownloadError.invalidResponse | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| guard httpResponse.statusCode == 200 else { | ||||||||||||||||||||||||||||||
| throw DownloadError.httpError(httpResponse.statusCode) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Verify content length if provided | ||||||||||||||||||||||||||||||
| if let expectedSize = task.expectedSize { | ||||||||||||||||||||||||||||||
| let actualSize = httpResponse.expectedContentLength | ||||||||||||||||||||||||||||||
| if actualSize != expectedSize && actualSize != -1 { | ||||||||||||||||||||||||||||||
| throw DownloadError.sizeMismatch(expected: expectedSize, actual: actualSize) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Verify checksum if provided | ||||||||||||||||||||||||||||||
| if let expectedChecksum = task.expectedChecksum { | ||||||||||||||||||||||||||||||
| let actualChecksum = try calculateSHA256(for: tempURL) | ||||||||||||||||||||||||||||||
| if actualChecksum != expectedChecksum { | ||||||||||||||||||||||||||||||
| throw DownloadError.checksumMismatch(expected: expectedChecksum, actual: actualChecksum) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Move to final destination | ||||||||||||||||||||||||||||||
| if FileManager.default.fileExists(atPath: task.destination.path) { | ||||||||||||||||||||||||||||||
| try FileManager.default.removeItem(at: task.destination) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try FileManager.default.moveItem(at: tempURL, to: task.destination) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Final progress update | ||||||||||||||||||||||||||||||
| let fileSize = try FileManager.default.attributesOfItem(atPath: task.destination.path)[.size] as? Int64 ?? 0 | ||||||||||||||||||||||||||||||
| let finalProgress = DownloadProgress( | ||||||||||||||||||||||||||||||
| bytesDownloaded: fileSize, | ||||||||||||||||||||||||||||||
| totalBytes: fileSize, | ||||||||||||||||||||||||||||||
| percentage: 1.0, | ||||||||||||||||||||||||||||||
| status: .completed | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| progress(finalProgress) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return task.destination | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private func calculateSHA256(for url: URL) throws -> String { | ||||||||||||||||||||||||||||||
| let data = try Data(contentsOf: url) | ||||||||||||||||||||||||||||||
| let hash = SHA256.hash(data: data) | ||||||||||||||||||||||||||||||
| return hash.compactMap { String(format: "%02x", $0) }.joined() | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+93
to
+97
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current implementation of private func calculateSHA256(for url: URL) throws -> String {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var hasher = SHA256()
while autoreleasepool(invoking: {
let chunk = handle.readData(ofLength: 1024 * 1024) // 1MB chunks
if chunk.isEmpty {
return false
}
hasher.update(data: chunk)
return true
}) {}
let hash = hasher.finalize()
return hash.compactMap { String(format: "%02x", $0) }.joined()
} |
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| func cancelDownload(_ url: URL) { | ||||||||||||||||||||||||||||||
| activeDownloads.removeValue(forKey: url) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+99
to
+101
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| func cancelAllDownloads() { | ||||||||||||||||||||||||||||||
| activeDownloads.removeAll() | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+103
to
+105
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // MARK: - Supporting Types | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| class DownloadTask { | ||||||||||||||||||||||||||||||
| let url: URL | ||||||||||||||||||||||||||||||
| let destination: URL | ||||||||||||||||||||||||||||||
| let expectedSize: Int64? | ||||||||||||||||||||||||||||||
| let expectedChecksum: String? | ||||||||||||||||||||||||||||||
| let result: CheckedContinuation<URL, Error> | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| init(url: URL, destination: URL, expectedSize: Int64?, expectedChecksum: String?) { | ||||||||||||||||||||||||||||||
| self.url = url | ||||||||||||||||||||||||||||||
| self.destination = destination | ||||||||||||||||||||||||||||||
| self.expectedSize = expectedSize | ||||||||||||||||||||||||||||||
| self.expectedChecksum = expectedChecksum | ||||||||||||||||||||||||||||||
| self.result = CheckedContinuation<URL, Error>() | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+110
to
+124
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The To achieve download coalescing (allowing multiple callers to await the result of a single download), you should store a |
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| struct DownloadProgress: Sendable { | ||||||||||||||||||||||||||||||
| let bytesDownloaded: Int64 | ||||||||||||||||||||||||||||||
| let totalBytes: Int64 | ||||||||||||||||||||||||||||||
| let percentage: Double | ||||||||||||||||||||||||||||||
| let status: DownloadStatus | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| var formattedBytesDownloaded: String { | ||||||||||||||||||||||||||||||
| ByteCountFormatter.string(fromByteCount: bytesDownloaded, countStyle: .file) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| var formattedTotalBytes: String { | ||||||||||||||||||||||||||||||
| ByteCountFormatter.string(fromByteCount: totalBytes, countStyle: .file) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| var formattedProgress: String { | ||||||||||||||||||||||||||||||
| "\(formattedBytesDownloaded) / \(formattedTotalBytes)" | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| enum DownloadStatus: Sendable { | ||||||||||||||||||||||||||||||
| case waiting | ||||||||||||||||||||||||||||||
| case downloading | ||||||||||||||||||||||||||||||
| case completed | ||||||||||||||||||||||||||||||
| case failed(Error) | ||||||||||||||||||||||||||||||
| case cancelled | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| enum DownloadError: LocalizedError, Sendable { | ||||||||||||||||||||||||||||||
| case invalidResponse | ||||||||||||||||||||||||||||||
| case httpError(Int) | ||||||||||||||||||||||||||||||
| case sizeMismatch(expected: Int64, actual: Int64) | ||||||||||||||||||||||||||||||
| case checksumMismatch(expected: String, actual: String) | ||||||||||||||||||||||||||||||
| case cancelled | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| var errorDescription: String? { | ||||||||||||||||||||||||||||||
| switch self { | ||||||||||||||||||||||||||||||
| case .invalidResponse: | ||||||||||||||||||||||||||||||
| return "Invalid server response" | ||||||||||||||||||||||||||||||
| case .httpError(let code): | ||||||||||||||||||||||||||||||
| return "HTTP error: \(code)" | ||||||||||||||||||||||||||||||
| case .sizeMismatch(let expected, let actual): | ||||||||||||||||||||||||||||||
| return "File size mismatch - expected \(expected), got \(actual)" | ||||||||||||||||||||||||||||||
| case .checksumMismatch(let expected, let actual): | ||||||||||||||||||||||||||||||
| return "Checksum verification failed - expected \(expected), got \(actual)" | ||||||||||||||||||||||||||||||
| case .cancelled: | ||||||||||||||||||||||||||||||
| return "Download was cancelled" | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // MARK: - URLSessionDownloadDelegate for Progress Tracking | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| extension Downloader: URLSessionDownloadDelegate { | ||||||||||||||||||||||||||||||
| nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| let progress = DownloadProgress( | ||||||||||||||||||||||||||||||
| bytesDownloaded: totalBytesWritten, | ||||||||||||||||||||||||||||||
| totalBytes: totalBytesExpectedToWrite > 0 ? totalBytesExpectedToWrite : totalBytesWritten, | ||||||||||||||||||||||||||||||
| percentage: totalBytesExpectedToWrite > 0 ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0.0, | ||||||||||||||||||||||||||||||
| status: .downloading | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Find the associated task and call its progress handler | ||||||||||||||||||||||||||||||
| // Note: This requires refactoring to store progress handlers per task | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { | ||||||||||||||||||||||||||||||
| // Handle completion | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+178
to
+195
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This
To get progress updates, you should choose one of these approaches:
|
||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| import Foundation | ||
|
|
||
| struct GitHub { | ||
| struct Release: Decodable { | ||
| let tag_name: String | ||
| let name: String? | ||
| let body: String? | ||
| let draft: Bool | ||
| let prerelease: Bool | ||
| let published_at: String? | ||
| let assets: [Asset] | ||
| } | ||
|
|
||
| struct Asset: Decodable { | ||
| let name: String | ||
| let browser_download_url: String | ||
| let content_type: String | ||
| let size: Int | ||
| } | ||
|
Comment on lines
+4
to
+19
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The property names in struct Release: Decodable {
let tagName: String
let name: String?
let body: String?
let draft: Bool
let prerelease: Bool
let publishedAt: String?
let assets: [Asset]
enum CodingKeys: String, CodingKey {
case name, body, draft, prerelease, assets
case tagName = "tag_name"
case publishedAt = "published_at"
}
}
struct Asset: Decodable {
let name: String
let browserDownloadURL: String
let contentType: String
let size: Int
enum CodingKeys: String, CodingKey {
case name, size
case browserDownloadURL = "browser_download_url"
case contentType = "content_type"
}
} |
||
|
|
||
| static func latest(owner: String, repo: String, token: String? = nil) async throws -> Release { | ||
| var request = URLRequest(url: URL(string: "https://api.github.com/repos/\(owner)/\(repo)/releases/latest")!) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Force-unwrapping the URL with guard let url = URL(string: "https://api.github.com/repos/\(owner)/\(repo)/releases/latest") else {
throw URLError(.badURL)
}
var request = URLRequest(url: url) |
||
| request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") | ||
| request.setValue("AutoUp/1.0", forHTTPHeaderField: "User-Agent") | ||
|
|
||
| if let token = token { | ||
| request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") | ||
| } | ||
|
|
||
| let (data, response) = try await URLSession.shared.data(for: request) | ||
|
|
||
| guard let httpResponse = response as? HTTPURLResponse else { | ||
| throw URLError(.badServerResponse) | ||
| } | ||
|
|
||
| if httpResponse.statusCode == 403 { | ||
| throw GitHubError.rateLimited | ||
| } | ||
|
|
||
| guard httpResponse.statusCode == 200 else { | ||
| throw GitHubError.apiError(httpResponse.statusCode) | ||
| } | ||
|
|
||
| return try JSONDecoder().decode(Release.self, from: data) | ||
| } | ||
|
|
||
| static func releases(owner: String, repo: String, count: Int = 10, token: String? = nil) async throws -> [Release] { | ||
| var request = URLRequest(url: URL(string: "https://api.github.com/repos/\(owner)/\(repo)/releases?per_page=\(count)")!) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Force-unwrapping the URL with guard let url = URL(string: "https://api.github.com/repos/\(owner)/\(repo)/releases?per_page=\(count)") else {
throw URLError(.badURL)
}
var request = URLRequest(url: url) |
||
| request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") | ||
| request.setValue("AutoUp/1.0", forHTTPHeaderField: "User-Agent") | ||
|
|
||
| if let token = token { | ||
| request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") | ||
| } | ||
|
|
||
| let (data, response) = try await URLSession.shared.data(for: request) | ||
|
|
||
| guard let httpResponse = response as? HTTPURLResponse, | ||
| httpResponse.statusCode == 200 else { | ||
| throw URLError(.badServerResponse) | ||
| } | ||
|
|
||
| return try JSONDecoder().decode([Release].self, from: data) | ||
| } | ||
|
|
||
| enum GitHubError: LocalizedError { | ||
| case rateLimited | ||
| case apiError(Int) | ||
| case invalidRepo | ||
|
|
||
| var errorDescription: String? { | ||
| switch self { | ||
| case .rateLimited: | ||
| return "GitHub API rate limit exceeded" | ||
| case .apiError(let code): | ||
| return "GitHub API error: \(code)" | ||
| case .invalidRepo: | ||
| return "Invalid GitHub repository" | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The property names in
CaskInfousesnake_case, which deviates from the Swift API Design Guidelines that recommendcamelCase. To align with Swift conventions while still parsing the JSON correctly, you can rename the properties tocamelCaseand provide aCodingKeysenum to map them to their originalsnake_casekeys in the JSON.