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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

To adhere to Swift's API Design Guidelines, it's recommended to use camelCase for property names. You can use a CodingKeys enum to map the snake_case keys from the JSON payload to your camelCase properties. This improves consistency across your Swift codebase.

    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, "")
}
}

private static func shellQuote(_ string: String) -> String {
return "'\(string.replacingOccurrences(of: "'", with: "'\\''"))'"
}
}
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

For consistency with Swift API Design Guidelines, it's best to use camelCase for property names and map the snake_case keys from the GitHub API JSON response using a CodingKeys enum. This makes the Swift code more idiomatic.

    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")!)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

Force-unwrapping a URL created from a string is unsafe and can lead to a runtime crash if the owner or repo strings contain characters that result in an invalid URL. You should safely unwrap the URL using a guard statement and throw an error if it's invalid.

        guard let url = URL(string: "https://api.github.com/repos/\(owner)/\(repo)/releases/latest") else {
            throw GitHubError.invalidRepo
        }
        var request = URLRequest(url: url)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: URL Initialization Crashes with Invalid Characters

The latest() and releases() functions force unwrap the URL(string:) initializer. If the owner or repo parameters contain characters that create an invalid URL string, this will cause the app to crash.

Additional Locations (2)

Fix in Cursor Fix in Web

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)")!)
Copy link
Copy Markdown

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 dangerous as it can cause a runtime crash if the interpolated strings create an invalid URL. Please use safe URL construction, for example with a guard statement, to prevent potential crashes.

        guard let url = URL(string: "https://api.github.com/repos/\(owner)/\(repo)/releases?per_page=\(count)") else {
            throw GitHubError.invalidRepo
        }
        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)
}
Comment on lines +58 to +61
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The error handling here is inconsistent with the latest function. This function throws a generic URLError(.badServerResponse) for any non-200 status code, whereas latest handles rate limiting (403) and other errors more specifically. It would be better to unify this logic to provide more specific GitHubError types, which will help with debugging and error reporting.

        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)
}

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"
}
}
}
}
136 changes: 136 additions & 0 deletions Sources/Core/Installer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import Foundation

enum InstallerError: LocalizedError {
case noAppFound
case dmgAttachFailed(Int32)
case pkgInstallFailed(Int32)
case codesignFailed
case backupFailed

var errorDescription: String? {
switch self {
case .noAppFound:
return "Couldn't find the app in the download"
case .dmgAttachFailed(let code):
return "DMG mount failed with code \(code)"
case .pkgInstallFailed(let code):
return "PKG install failed with code \(code)"
case .codesignFailed:
return "App signature verification failed"
case .backupFailed:
return "Couldn't backup current version"
}
}
}

enum Installer {
static func installZIP(from zipURL: URL, toApplications name: String, bundleID: String, currentVersion: String) throws {
// Create backup first
let currentAppPath = "/Applications/\(name).app"
if FileManager.default.fileExists(atPath: currentAppPath) {
_ = try? SecurityChecks.backup(appPath: currentAppPath, bundleID: bundleID, version: currentVersion)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Using _ = try? to handle the backup operation is risky. It silently ignores any errors during the backup process. If the backup fails and the subsequent installation also fails, the user could be left with no working version of the application. The backup failure should be handled explicitly, likely by throwing an error and aborting the update.

Suggested change
_ = try? SecurityChecks.backup(appPath: currentAppPath, bundleID: bundleID, version: currentVersion)
try SecurityChecks.backup(appPath: currentAppPath, bundleID: bundleID, version: currentVersion)

}

let tmp = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tmp) }

_ = try run("/usr/bin/unzip", ["-qq", zipURL.path, "-d", tmp.path])
let app = try findApp(in: tmp)

// Verify codesign before installing
guard SecurityChecks.verifyCodeSign(app.path) else {
throw InstallerError.codesignFailed
}

try moveToApplications(app)
}

static func installDMG(from dmgURL: URL, bundleID: String, currentVersion: String) throws {
// Create backup first
let apps = try? FileManager.default.contentsOfDirectory(atPath: "/Applications")
let currentAppPath = apps?.first { $0.hasSuffix(".app") && Bundle(path: "/Applications/\($0)")?.bundleIdentifier == bundleID }

if let appPath = currentAppPath {
let fullPath = "/Applications/\(appPath)"
_ = try? SecurityChecks.backup(appPath: fullPath, bundleID: bundleID, version: currentVersion)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Ignoring a potential backup failure with _ = try? is dangerous. If the backup fails, the installation proceeds, which could lead to data loss if the installation also fails. It's safer to let the error propagate by using try and abort the update process if a backup can't be created.

Suggested change
_ = try? SecurityChecks.backup(appPath: fullPath, bundleID: bundleID, version: currentVersion)
try SecurityChecks.backup(appPath: fullPath, bundleID: bundleID, version: currentVersion)

}

let (code, out) = try run("/usr/bin/hdiutil", ["attach", "-nobrowse", "-quiet", dmgURL.path])
guard code == 0 else {
throw InstallerError.dmgAttachFailed(code)
}

guard let mount = out.split(separator: "\t").last.map(String.init) else {
throw InstallerError.dmgAttachFailed(-1)
}
Comment on lines +64 to +66
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Parsing the string output from hdiutil by splitting on tabs is brittle. This can easily break with future macOS updates or different system locales. A more robust method is to use the -plist flag with hdiutil attach, which provides structured XML output. You can then parse this plist to reliably extract the mount point.


defer { _ = try? run("/usr/bin/hdiutil", ["detach", "-quiet", mount]) }

let app = try findApp(in: URL(fileURLWithPath: mount))

// Verify codesign before installing
guard SecurityChecks.verifyCodeSign(app.path) else {
throw InstallerError.codesignFailed
}

try moveToApplications(app)
}

static func installPKG(from pkgURL: URL) throws {
let (code, _) = try run("/usr/sbin/installer", ["-pkg", pkgURL.path, "-target", "/"])
guard code == 0 else {
throw InstallerError.pkgInstallFailed(code)
}
}

// MARK: - Private Helpers

private static func findApp(in dir: URL) throws -> URL {
let items = try FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)
if let app = items.first(where: { $0.pathExtension == "app" }) {
return app
}

// Recursive search in case of subfolders
for url in items where url.hasDirectoryPath {
if let app = try? findApp(in: url) {
return app
}
}

throw InstallerError.noAppFound
}

private static func moveToApplications(_ src: URL) throws {
let dst = URL(fileURLWithPath: "/Applications").appendingPathComponent(src.lastPathComponent)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The /Applications path is hardcoded. While this is the default on macOS, it's not guaranteed. To make the code more robust and support non-standard configurations, you should retrieve the applications directory path dynamically using FileManager.default.urls(for: .applicationDirectory, in: .localDomainMask).

Suggested change
let dst = URL(fileURLWithPath: "/Applications").appendingPathComponent(src.lastPathComponent)
guard let applicationsURL = FileManager.default.urls(for: .applicationDirectory, in: .localDomainMask).first else {
// This should ideally throw a specific error
throw InstallerError.noAppFound
}
let dst = applicationsURL.appendingPathComponent(src.lastPathComponent)


if FileManager.default.fileExists(atPath: dst.path) {
try FileManager.default.removeItem(at: dst)
}

try FileManager.default.copyItem(at: src, to: dst)

// Remove quarantine if present
_ = SecurityChecks.removeQuarantine(dst.path)
}

@discardableResult
private static func run(_ bin: String, _ args: [String]) throws -> (Int32, String) {
let process = Process()
process.executableURL = URL(fileURLWithPath: bin)
process.arguments = args

let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe

try process.run()
process.waitUntilExit()

let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""

return (process.terminationStatus, output)
}
}
76 changes: 76 additions & 0 deletions Sources/Core/RepoHints.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Foundation

// Curated mapping of bundle IDs to GitHub repositories
let RepoHints: [String: (owner: String, repo: String)] = [
// Developer Tools
"com.microsoft.VSCode": ("microsoft", "vscode"),
"com.github.GitHubDesktop": ("desktop", "desktop"),
"com.figma.Desktop": ("figma", "figma-linux"),
"com.postmanlabs.mac": ("postmanlabs", "postman-app-support"),

// Productivity
"com.raycast.macos": ("raycast", "raycast"),
"com.electron.reeder.5": ("reederapp", "reeder5"),
"com.culturedcode.ThingsMac": ("culturedcode", "things-mac"),
"com.flexibits.fantastical2.mac": ("flexibits", "fantastical-mac"),

// Media & Design
"org.blender": ("blender", "blender"),
"com.spotify.client": ("spotify", "spotify-desktop"),
"com.getdavinci.DaVinciResolve": ("blackmagicdesign", "davinci-resolve"),

// Communication
"com.tinyspeck.slackmacgap": ("slack", "slack-desktop"),
"com.microsoft.teams2": ("microsoft", "teams-desktop"),
"ru.keepcoder.Telegram": ("telegramdesktop", "tdesktop"),

// Utilities
"com.1password.1password": ("1password", "1password-desktop"),
"com.objective-see.lulu.app": ("objective-see", "lulu"),
"com.posthog.desktop": ("posthog", "posthog-desktop"),
"com.sindresorhus.CleanMyMac": ("sindresorhus", "cleanmymac"),

// Browsers
"com.google.Chrome": ("google", "chrome"),
"com.microsoft.edgemac": ("microsoft", "edge"),
"com.brave.Browser": ("brave", "brave-browser"),

// Open Source
"org.videolan.vlc": ("videolan", "vlc"),
"org.mozilla.firefox": ("mozilla", "firefox"),
"com.openemu.OpenEmu": ("openemu", "openemu"),
]

enum RepoDiscovery {
static func guessRepository(for bundleID: String, appName: String) -> (owner: String, repo: String)? {
// Check our curated list first
if let repo = RepoHints[bundleID] {
return repo
}

// Try common patterns
let cleanName = appName.lowercased()
.replacingOccurrences(of: " ", with: "-")
.replacingOccurrences(of: ".", with: "")

// Common organization patterns
let commonOwners = [
cleanName,
"\(cleanName)-team",
"\(cleanName)app",
"electron-apps"
]

// Return first guess (caller should validate)
return (owner: commonOwners.first ?? cleanName, repo: cleanName)
}

static func validateRepository(owner: String, repo: String) async -> Bool {
do {
_ = try await GitHub.latest(owner: owner, repo: repo)
return true
} catch {
return false
}
}
}
Loading