import Foundation import AppKit import CommonCrypto /// Handles automatic self-update by comparing the local DMG SHA256 against /// the one published in the repository. When a mismatch is detected the new /// DMG is downloaded, mounted, and the app is reinstalled + relaunched. enum AutoUpdater { // MARK: - Configuration /// Raw URL for the SHA256 file in the repo (Gitea raw endpoint) private static let remoteSHA256URL = URL( string: "https://git.nixc.us/colin/pommedoro/raw/branch/main/releases/Pommedoro.dmg.sha256" )! /// Raw URL for the DMG itself private static let remoteDMGURL = URL( string: "https://git.nixc.us/colin/pommedoro/raw/branch/main/releases/Pommedoro.dmg" )! /// Where to store the local copy of the last-known SHA256 private static var localSHAPath: URL { let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let dir = support.appendingPathComponent("Pommedoro", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir.appendingPathComponent("current.sha256") } /// Temporary download location for the DMG private static var downloadedDMGPath: URL { FileManager.default.temporaryDirectory.appendingPathComponent("Pommedoro-update.dmg") } // MARK: - Public API /// Check for updates in the background. Optionally show UI feedback. static func checkInBackground(silent: Bool = true) { DispatchQueue.global(qos: .utility).async { self.performCheck(silent: silent) } } // MARK: - Core Logic private static func performCheck(silent: Bool) { // 1. Fetch remote SHA256 guard let remoteSHA = fetchRemoteSHA() else { if !silent { showAlert(title: "Update Check Failed", message: "Could not reach the update server.") } return } // 2. Read local SHA256 (written at last install/build) let localSHA = readLocalSHA() // 3. Compare if remoteSHA == localSHA { if !silent { showAlert(title: "Up to Date", message: "You are running the latest version.") } return } // 4. There's an update – prompt user let shouldUpdate = promptForUpdate() guard shouldUpdate else { return } // 5. Download new DMG guard downloadDMG() else { showAlert(title: "Update Failed", message: "Could not download the update.") return } // 6. Verify downloaded DMG matches remote SHA guard let downloadedSHA = sha256(of: downloadedDMGPath), downloadedSHA == remoteSHA else { showAlert(title: "Update Failed", message: "Downloaded file integrity check failed.") try? FileManager.default.removeItem(at: downloadedDMGPath) return } // 7. Mount, install, relaunch guard installFromDMG() else { showAlert(title: "Update Failed", message: "Could not install the update.") return } // 8. Save new SHA so we don't re-update next launch try? remoteSHA.write(to: localSHAPath, atomically: true, encoding: .utf8) // 9. Relaunch relaunch() } // MARK: - Networking private static func fetchRemoteSHA() -> String? { let sem = DispatchSemaphore(value: 0) var result: String? let task = URLSession.shared.dataTask(with: remoteSHA256URL) { data, response, _ in defer { sem.signal() } guard let data = data, let http = response as? HTTPURLResponse, http.statusCode == 200, let str = String(data: data, encoding: .utf8) else { return } result = str.trimmingCharacters(in: .whitespacesAndNewlines) } task.resume() sem.wait() return result } private static func downloadDMG() -> Bool { let sem = DispatchSemaphore(value: 0) var success = false let task = URLSession.shared.downloadTask(with: remoteDMGURL) { url, response, _ in defer { sem.signal() } guard let url = url, let http = response as? HTTPURLResponse, http.statusCode == 200 else { return } do { let dest = downloadedDMGPath try? FileManager.default.removeItem(at: dest) try FileManager.default.moveItem(at: url, to: dest) success = true } catch { BugReporting.capture(error) } } task.resume() sem.wait() return success } // MARK: - SHA256 Computation private static func sha256(of fileURL: URL) -> String? { guard let handle = try? FileHandle(forReadingFrom: fileURL) else { return nil } defer { handle.closeFile() } var ctx = CC_SHA256_CTX() CC_SHA256_Init(&ctx) while autoreleasepool(invoking: { let chunk = handle.readData(ofLength: 1_048_576) // 1 MB if chunk.isEmpty { return false } chunk.withUnsafeBytes { ptr in _ = CC_SHA256_Update(&ctx, ptr.baseAddress, CC_LONG(chunk.count)) } return true }) {} var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) CC_SHA256_Final(&digest, &ctx) return digest.map { String(format: "%02x", $0) }.joined() } // MARK: - Local SHA private static func readLocalSHA() -> String? { guard let str = try? String(contentsOf: localSHAPath, encoding: .utf8) else { return nil } return str.trimmingCharacters(in: .whitespacesAndNewlines) } /// Write the current SHA256 so subsequent update checks know the baseline. static func writeLocalSHA(_ sha: String) { try? sha.write(to: localSHAPath, atomically: true, encoding: .utf8) } // MARK: - DMG Install private static func installFromDMG() -> Bool { let dmg = downloadedDMGPath.path let mountPoint = "/tmp/pommedoro-update-mount" // Mount let mountOK = runShell("hdiutil attach \"\(dmg)\" -mountpoint \"\(mountPoint)\" -nobrowse -quiet") guard mountOK else { return false } defer { _ = runShell("hdiutil detach \"\(mountPoint)\" -quiet") try? FileManager.default.removeItem(at: downloadedDMGPath) } let srcApp = "\(mountPoint)/Pommedoro.app" let dstApp = "/Applications/Pommedoro.app" guard FileManager.default.fileExists(atPath: srcApp) else { return false } // Remove old and copy new try? FileManager.default.removeItem(atPath: dstApp) do { try FileManager.default.copyItem(atPath: srcApp, toPath: dstApp) } catch { BugReporting.capture(error) return false } // Strip quarantine _ = runShell("xattr -cr \"\(dstApp)\"") return true } // MARK: - Shell Helper @discardableResult private static func runShell(_ command: String) -> Bool { let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/bash") proc.arguments = ["-c", command] proc.standardOutput = FileHandle.nullDevice proc.standardError = FileHandle.nullDevice do { try proc.run() proc.waitUntilExit() return proc.terminationStatus == 0 } catch { return false } } // MARK: - Relaunch private static func relaunch() { let appPath = "/Applications/Pommedoro.app" let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/open") task.arguments = ["-n", appPath] try? task.run() DispatchQueue.main.async { NSApp.terminate(nil) } } // MARK: - User Prompts private static func promptForUpdate() -> Bool { var result = false let sem = DispatchSemaphore(value: 0) DispatchQueue.main.async { let alert = NSAlert() alert.messageText = "Update Available" alert.informativeText = "A new version of Pommedoro is available. Would you like to update now?" alert.alertStyle = .informational alert.addButton(withTitle: "Update") alert.addButton(withTitle: "Later") result = alert.runModal() == .alertFirstButtonReturn sem.signal() } sem.wait() return result } private static func showAlert(title: String, message: String) { DispatchQueue.main.async { let alert = NSAlert() alert.messageText = title alert.informativeText = message alert.alertStyle = .informational alert.addButton(withTitle: "OK") alert.runModal() } } }