Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import PackageDescription
let package = Package(
name: "AutoUp",
platforms: [
.macOS(.v13)
.macOS("13.3")
],
products: [
.executable(
Expand Down
77 changes: 77 additions & 0 deletions Sources/Core/Brew.swift
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?
}
Comment on lines +39 to +50

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The property names in CaskInfo use snake_case, which deviates from the Swift API Design Guidelines that recommend camelCase. To align with Swift conventions while still parsing the JSON correctly, you can rename the properties to camelCase and provide a CodingKeys enum to map them to their original snake_case keys in the JSON.

    struct CaskInfo: Decodable {
        let token: String
        let fullName: 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?

        enum CodingKeys: String, CodingKey {
            case token, tap, version, installed, outdated, homepage, url, name, desc
            case fullName = "full_name"
        }
    }


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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The run function currently catches errors from task.run() and returns a default tuple (-1, ""), which conceals the underlying issue and makes debugging difficult. It's better to allow the function to throw errors, so callers can handle them appropriately and have more context for debugging. You will need to update the call sites to handle the thrown error, for example by using try?.

    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: "'\\''"))'"
}
}
195 changes: 195 additions & 0 deletions Sources/Core/Downloader.swift
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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To receive delegate callbacks for download progress, the URLSession must be initialized with a delegate. The current implementation is missing this. You should set self as the delegate and provide a non-main delegate queue for background operations. Also, note that super.init() must be called after all subclass properties have been initialized.

Suggested change
override init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 300
self.session = URLSession(configuration: config)
super.init()
}
override init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 300
super.init()
self.session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
}


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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The current implementation of calculateSHA256 reads the entire file into memory using Data(contentsOf:). This can lead to excessive memory consumption and potential crashes when dealing with large files. A more memory-efficient approach is to read the file in chunks and update the hasher incrementally.

    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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The cancelDownload function only removes the DownloadTask from the activeDownloads dictionary but fails to cancel the underlying URLSessionDownloadTask. The download will continue in the background, consuming network and system resources. To properly implement cancellation, you need to store a reference to the URLSessionTask and call its cancel() method.


func cancelAllDownloads() {
activeDownloads.removeAll()
}
Comment on lines +103 to +105

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Similar to cancelDownload, cancelAllDownloads only clears the activeDownloads dictionary and does not cancel any in-progress URLSessionDownloadTasks. This will leave downloads running in the background. You need to iterate through the active tasks and explicitly cancel each one.

}

// 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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The DownloadTask class and its use of CheckedContinuation are fundamentally incorrect. A CheckedContinuation cannot be instantiated directly or stored as a property to be used later; it must be obtained from within a withCheckedThrowingContinuation closure and resumed exactly once. This implementation will not compile.

To achieve download coalescing (allowing multiple callers to await the result of a single download), you should store a Task<URL, Error> in your DownloadTask instead. When a download is first requested, you can create and store a new Task that performs the download. Subsequent requests for the same URL can then simply await the result of this stored task.


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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This URLSessionDownloadDelegate extension will not function as intended for two main reasons:

  1. The URLSession is not initialized with a delegate, so these delegate methods will never be called.
  2. The download function uses await session.download(from:), an async/await API that does not use delegate methods for progress reporting.

To get progress updates, you should choose one of these approaches:

  • Delegate-based: Initialize URLSession with self as the delegate, use downloadTask(with:), and bridge the delegate callbacks to an async function using withCheckedThrowingContinuation.
  • Async Bytes: Use URLSession.bytes(from:) to get an AsyncByteSequence. You can then iterate over it to track progress while manually writing the data to a file.

82 changes: 82 additions & 0 deletions Sources/Core/GitHub.swift
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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The property names in Release and Asset use snake_case, which is not conventional in Swift. Please use camelCase for property names and use CodingKeys to map them to the JSON fields. This improves adherence to Swift's API Design Guidelines.

    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")!)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Force-unwrapping the URL with ! can cause a runtime crash if the owner or repo strings contain characters that make the URL string invalid. You should construct the URL safely and handle the potential failure, for example by throwing an error.

        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)")!)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Force-unwrapping the URL with ! is unsafe and can lead to a crash if the URL string is invalid. Please construct the URL safely and handle the failure case.

        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"
}
}
}
}
Loading