import Foundation import AppKit import CommonCrypto /// Self-update by comparing local state (SHA + timestamp) to remote SHA + Last-Modified /// from the git server over HTTPS (no login). Update only when server is newer. enum AutoUpdater { private static let devSentinel = "dev" private static let remoteSHA256URL = URL( string: "https://git.nixc.us/colin/pommedoro/raw/branch/main/releases/Pommedoro.dmg.sha256" )! private static let remoteDMGURL = URL( string: "https://git.nixc.us/colin/pommedoro/raw/branch/main/releases/Pommedoro.dmg" )! private static var supportDir: 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 } private static var statePath: URL { supportDir.appendingPathComponent("current.sha256") } private static var downloadedDMGPath: URL { FileManager.default.temporaryDirectory.appendingPathComponent("Pommedoro-update.dmg") } private static var stagedAppPath: String { NSTemporaryDirectory() + "Pommedoro-staged.app" } private static var pendingStatePath: String { NSTemporaryDirectory() + "pommedoro-pending-state.txt" } static func checkInBackground(silent: Bool = true) { DispatchQueue.global(qos: .utility).async { performCheck(silent: silent) } } private static func performCheck(silent: Bool) { let local = readLocalState() if local?.sha == devSentinel { if !silent { showAlert(title: "Dev Build", message: "Auto-update is disabled for local dev builds.") } return } guard let remote = fetchRemoteRelease() else { if !silent { showAlert(title: "Update Check Failed", message: "Could not reach the update server.") } return } if remote.sha == local?.sha { if !silent { showAlert(title: "Up to Date", message: "You are running the latest version.") } return } let localTimestamp = local?.timestamp ?? 0 if remote.timestamp <= localTimestamp { if !silent { showAlert(title: "Up to Date", message: "You are running the latest version.") } return } guard promptForUpdate() else { return } guard downloadDMG() else { showAlert(title: "Update Failed", message: "Could not download the update.") return } guard let downloadedSHA = sha256(of: downloadedDMGPath), downloadedSHA == remote.sha else { showAlert(title: "Update Failed", message: "Downloaded file integrity check failed.") try? FileManager.default.removeItem(at: downloadedDMGPath) return } guard stageFromDMG() else { showAlert(title: "Update Failed", message: "Could not extract the update.") return } try? "\(remote.sha)\n\(Int(remote.timestamp))".write(toFile: pendingStatePath, atomically: true, encoding: .utf8) spawnSwapAndRelaunch() DispatchQueue.main.async { NSApp.terminate(nil) } } // MARK: - Fetch from git server (HTTPS, no login) private static func fetchRemoteRelease() -> (sha: String, timestamp: TimeInterval)? { let sem = DispatchSemaphore(value: 0) var out: (sha: String, timestamp: TimeInterval)? 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 sha = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), !sha.isEmpty else { return } let ts: TimeInterval if let lastMod = http.value(forHTTPHeaderField: "Last-Modified"), let date = Self.parseHTTPDate(lastMod) { ts = date.timeIntervalSince1970 } else { ts = 0 } out = (sha, ts) } task.resume() sem.wait() return out } private static func parseHTTPDate(_ s: String) -> Date? { let fmt = DateFormatter() fmt.locale = Locale(identifier: "en_US_POSIX") fmt.timeZone = TimeZone(identifier: "GMT") fmt.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" return fmt.date(from: s) } private static func downloadDMG() -> Bool { let sem = DispatchSemaphore(value: 0) var ok = false let task = URLSession.shared.downloadTask(with: remoteDMGURL) { url, response, _ in defer { sem.signal() } guard let url = url, (response as? HTTPURLResponse)?.statusCode == 200 else { return } do { try? FileManager.default.removeItem(at: downloadedDMGPath) try FileManager.default.moveItem(at: url, to: downloadedDMGPath) ok = true } catch { BugReporting.capture(error) } } task.resume() sem.wait() return ok } private static func sha256(of url: URL) -> String? { guard let handle = try? FileHandle(forReadingFrom: url) 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) if chunk.isEmpty { return false } chunk.withUnsafeBytes { _ = CC_SHA256_Update(&ctx, $0.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 state (SHA + timestamp, two lines) private static func readLocalState() -> (sha: String, timestamp: TimeInterval)? { guard let raw = try? String(contentsOf: statePath, encoding: .utf8) else { return nil } let lines = raw.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) guard let sha = lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !sha.isEmpty else { return nil } let ts: TimeInterval = lines.count > 1 ? (TimeInterval(lines[1]) ?? 0) : 0 return (sha, ts) } static func writeLocalSHA(_ sha: String) { try? sha.write(to: statePath, atomically: true, encoding: .utf8) } private static func stageFromDMG() -> Bool { let dmg = downloadedDMGPath.path let mountPoint = "/tmp/pommedoro-update-mount" guard runShell("hdiutil attach \"\(dmg)\" -mountpoint \"\(mountPoint)\" -nobrowse -quiet") else { return false } defer { _ = runShell("hdiutil detach \"\(mountPoint)\" -quiet") try? FileManager.default.removeItem(at: downloadedDMGPath) } let srcApp = "\(mountPoint)/Pommedoro.app" guard FileManager.default.fileExists(atPath: srcApp) else { return false } try? FileManager.default.removeItem(atPath: stagedAppPath) do { try FileManager.default.copyItem(atPath: srcApp, toPath: stagedAppPath) } catch { BugReporting.capture(error); return false } return true } /// Script: wait for process exit, swap app, write state from pending file, relaunch. private static func spawnSwapAndRelaunch() { let pid = ProcessInfo.processInfo.processIdentifier let staged = stagedAppPath let dest = "/Applications/Pommedoro.app" let stateFile = statePath.path let pending = pendingStatePath let script = """ #!/bin/bash for i in $(seq 1 20); do kill -0 \(pid) 2>/dev/null || break; sleep 0.5; done rm -rf "\(dest)" mv "\(staged)" "\(dest)" xattr -cr "\(dest)" if [ -f "\(pending)" ]; then mkdir -p "$(dirname "\(stateFile)")" cp "\(pending)" "\(stateFile)" rm -f "\(pending)" fi open "\(dest)" """ let scriptPath = NSTemporaryDirectory() + "pommedoro-swap.sh" try? script.write(toFile: scriptPath, atomically: true, encoding: .utf8) let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/bash") proc.arguments = [scriptPath] proc.standardOutput = FileHandle.nullDevice proc.standardError = FileHandle.nullDevice proc.qualityOfService = .utility try? proc.run() } 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 guard (try? proc.run()) != nil else { return false } proc.waitUntilExit() return proc.terminationStatus == 0 } 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. 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 a = NSAlert() a.messageText = title a.informativeText = message a.alertStyle = .informational a.addButton(withTitle: "OK") a.runModal() } } }