pommedoro/Sources/Pommedoro/AutoUpdater.swift

265 lines
8.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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