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
Comment on lines +4 to +6

Choose a reason for hiding this comment

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

high

This implementation is inefficient as it fetches all outdated casks and then uses grep to find a match. A more robust and efficient approach would be to use brew outdated --cask --json, parse the JSON output, and check if the target cask is present in the resulting list. This avoids the overhead of grep and is less prone to breaking if the command's text output format changes.

    static func caskIsOutdated(_ cask: String) -> Bool {
        let result = run("brew outdated --cask --json")
        guard result.exitCode == 0, let data = result.output.data(using: .utf8) else {
            return false
        }

        struct OutdatedCasks: Decodable {
            let casks: [CaskInfo]
        }
        struct CaskInfo: Decodable {
            let name: String
        }

        if let outdated = try? JSONDecoder().decode(OutdatedCasks.self, from: data) {
            return outdated.casks.contains { $0.name == cask }
        }

        return false
    }

}

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 is common in JSON APIs but not idiomatic in Swift. For better code style and maintainability, you should use camelCase for property names and configure the JSONDecoder to convert from snake case automatically. This makes your Swift code cleaner and more consistent.1

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

Style Guide References

Footnotes

  1. Swift API Design Guidelines recommend using camelCase for property names to maintain consistency with Swift's naming conventions.


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
}

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 URL(string: ...)! is unsafe and can cause a runtime crash if the owner or repo strings contain characters that are invalid in a URL. You should use URLComponents to construct the URL safely, which will handle proper encoding and prevent crashes.

Suggested change
var request = URLRequest(url: URL(string: "https://api.github.com/repos/\(owner)/\(repo)/releases/latest")!)
guard var urlComponents = URLComponents(string: "https://api.github.com/repos/\(owner)/\(repo)/releases/latest") else {
throw GitHubError.invalidRepo
}
var request = URLRequest(url: urlComponents.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)

Choose a reason for hiding this comment

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

medium

To make your Decodable structs more idiomatic in Swift, you should use camelCase for property names (e.g., tagName, publishedAt). You can achieve this by setting the keyDecodingStrategy on your JSONDecoder instance to .convertFromSnakeCase.

        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        return try decoder.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)")!)
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"
}
}
}
}
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)

Choose a reason for hiding this comment

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

high

Silently ignoring a backup failure with try? is risky. The backup mechanism is critical for the rollback feature. If a backup fails, the installation should be aborted to prevent leaving the system in a state where rollback is not possible. You should handle the error explicitly. This also applies to the backup logic in installDMG.

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

let (code, out) = try run("/usr/bin/hdiutil", ["attach", "-nobrowse", "-quiet", dmgURL.path])

Choose a reason for hiding this comment

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

medium

Parsing the plain text output of hdiutil is fragile and can break if the output format changes in a future macOS version. You can make this more robust by using the -plist flag (hdiutil attach -plist ...), which provides a structured XML output that can be safely parsed.

guard code == 0 else {
throw InstallerError.dmgAttachFailed(code)
}

guard let mount = out.split(separator: "\t").last.map(String.init) else {
throw InstallerError.dmgAttachFailed(-1)
}

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)

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

try FileManager.default.copyItem(at: src, to: dst)
Comment on lines +105 to +112

Choose a reason for hiding this comment

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

high

The process of replacing an application by first removing the old version and then copying the new one is not atomic. If the copy operation fails, the user is left with no version of the application. A safer approach is to first copy the new version to a temporary name in the /Applications directory, and then use FileManager.replaceItemAt to atomically replace the old version with the new one. This ensures the user always has a working version of the app.


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