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
}
Comment on lines +25 to +27

Choose a reason for hiding this comment

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

medium

The catch block currently swallows the error, which can make debugging difficult if JSON decoding fails for an unexpected reason. Consider logging the error to aid in future troubleshooting.

}

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 properties in CaskInfo use snake_case, which doesn't follow the Swift API Design Guidelines that recommend camelCase. To align with Swift conventions and improve readability, you can rename the properties to camelCase and use a JSONDecoder with keyDecodingStrategy = .convertFromSnakeCase when decoding.

    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"

Choose a reason for hiding this comment

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

medium

The launchPath property is deprecated in macOS 10.13 and later. You should use executableURL instead for better future compatibility.

        task.executableURL = URL(fileURLWithPath: "/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

Choose a reason for hiding this comment

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

medium

The properties in Release and Asset use snake_case. While this matches the GitHub API response, the Swift convention is to use camelCase. You can easily handle this by setting the keyDecodingStrategy on your JSONDecoder instance to .convertFromSnakeCase. This avoids having to define CodingKeys manually.

Suggested change
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
}
struct Release: Decodable {
let tagName: String
let name: String?
let body: String?
let draft: Bool
let prerelease: Bool
let publishedAt: String?
let assets: [Asset]
}
struct Asset: Decodable {
let name: String
let browserDownloadUrl: String
let contentType: 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.

high

Force-unwrapping the URL with ! can lead to a crash if URL creation fails. Although unlikely with this specific string, it's safer to handle the optional URL gracefully. Consider using URLComponents for building complex URLs or at least a guard let to safely unwrap it.

Suggested change
var request = URLRequest(url: URL(string: "https://api.github.com/repos/\(owner)/\(repo)/releases/latest")!)
guard let url = URL(string: "https://api.github.com/repos/\(owner)/\(repo)/releases/latest") 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 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

When decoding the JSON, you can set the keyDecodingStrategy to automatically convert from snake_case to camelCase, which is more idiomatic in Swift. This would complement the change to camelCase properties in your Release and Asset structs.

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

Choose a reason for hiding this comment

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

high

Force-unwrapping the URL with ! can cause a runtime crash. It's safer to construct the URL and handle the possibility of failure, for example by using guard let and throwing an error.

        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

Choose a reason for hiding this comment

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

medium

The error handling here is less specific than in the latest(owner:repo:token:) function. It would be beneficial to provide more granular error information, such as for rate limiting (403) or other API errors, to improve debugging and user feedback. Consider adopting the same error handling logic used in the latest function.

        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)

Choose a reason for hiding this comment

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

high

Ignoring a failed backup with try? can lead to data loss if the subsequent update fails and the user wants to roll back. Since a backup is a critical part of a safe update process, you should handle this error. Consider removing try? and letting the error propagate, or catching it and throwing a specific InstallerError.backupFailed.

        do {
            _ = try SecurityChecks.backup(appPath: currentAppPath, bundleID: bundleID, version: currentVersion)
        } catch {
            throw InstallerError.backupFailed
        }

}

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

Choose a reason for hiding this comment

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

medium

Parsing the output of hdiutil by splitting the string is fragile and might break with different system languages or future updates to hdiutil. A more robust approach is to use the -plist flag (hdiutil attach -plist ...), which provides structured XML output that can be reliably parsed to get 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)

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)
}
Comment on lines +119 to +135

Choose a reason for hiding this comment

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

medium

This run function for executing shell commands is very similar to helper functions in Brew.swift, LaunchAgent.swift, and MAS.swift. To improve maintainability and reduce code duplication, consider creating a single, shared utility for running shell commands. This utility could be placed in a common Utils file and be used across the project.

}
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"),
Comment on lines +6 to +41

Choose a reason for hiding this comment

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

medium

Some of the repository mappings in RepoHints appear to be incorrect or may not lead to downloadable releases for macOS. For example:

  • com.google.Chrome points to google/chrome, which is not where Chrome releases are hosted.
  • com.figma.Desktop points to figma/figma-linux, which is for Linux.

It would be beneficial to verify this list to ensure the repository discovery feature works as expected.

]

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)

Choose a reason for hiding this comment

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

high

The logic for guessing a repository owner is flawed. commonOwners.first will always be cleanName, making the ?? cleanName part redundant and ignoring all other potential owners in the commonOwners array. The function will always return (owner: cleanName, repo: cleanName). The implementation should be revised to actually try the different patterns listed in commonOwners.

}

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