pommedoro/Sources/Pommedoro/AutoUpdater.swift

309 lines
11 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
/// Sentinel value written by `make install` to indicate a dev build.
/// The updater skips automatic updates when this is the local stamp.
private static let devSentinel = "dev"
/// 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")
}
/// Staging location for the new .app before swap
private static var stagedAppPath: String {
NSTemporaryDirectory() + "Pommedoro-staged.app"
}
// 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. Read local SHA256 (written at last install/build)
let localSHA = readLocalSHA()
// 2. Dev builds are stamped "dev" skip silent checks entirely
if localSHA == devSentinel {
if !silent {
showAlert(title: "Dev Build", message: "Auto-update is disabled for local dev builds.")
}
return
}
// 3. Fetch remote SHA256
guard let remoteSHA = fetchRemoteSHA() else {
if !silent { showAlert(title: "Update Check Failed", message: "Could not reach the update server.") }
return
}
// 4. Compare
if remoteSHA == localSHA {
if !silent { showAlert(title: "Up to Date", message: "You are running the latest version.") }
return
}
// 5. There's an update prompt user
let shouldUpdate = promptForUpdate()
guard shouldUpdate else { return }
// 6. Download new DMG
guard downloadDMG() else {
showAlert(title: "Update Failed", message: "Could not download the update.")
return
}
// 7. 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
}
// 8. Extract new app from DMG to staging area
guard stageFromDMG() else {
showAlert(title: "Update Failed", message: "Could not extract the update.")
return
}
// 9. Save new SHA so we don't re-update next launch
try? remoteSHA.write(to: localSHAPath, atomically: true, encoding: .utf8)
// 10. Spawn swap script, then terminate so applicationWillTerminate saves state
spawnSwapAndRelaunch()
DispatchQueue.main.async {
NSApp.terminate(nil)
}
}
// 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
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: - Stage from DMG
/// Mount the downloaded DMG and copy the .app to a temp staging directory.
/// This avoids replacing the running binary in-place.
private static func stageFromDMG() -> Bool {
let dmg = downloadedDMGPath.path
let mountPoint = "/tmp/pommedoro-update-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"
guard FileManager.default.fileExists(atPath: srcApp) else { return false }
// Copy to staging
try? FileManager.default.removeItem(atPath: stagedAppPath)
do {
try FileManager.default.copyItem(atPath: srcApp, toPath: stagedAppPath)
} catch {
BugReporting.capture(error)
return false
}
return true
}
// MARK: - Swap and Relaunch
/// Spawns a background shell script that:
/// 1. Waits for the current process to exit
/// 2. Replaces /Applications/Pommedoro.app with the staged copy
/// 3. Strips quarantine
/// 4. Relaunches the app
/// The script is fire-and-forget so the current process can terminate cleanly.
private static func spawnSwapAndRelaunch() {
let pid = ProcessInfo.processInfo.processIdentifier
let staged = stagedAppPath
let dest = "/Applications/Pommedoro.app"
let script = """
#!/bin/bash
# Wait for the old process to exit (up to 10 seconds)
for i in $(seq 1 20); do
kill -0 \(pid) 2>/dev/null || break
sleep 0.5
done
# Swap
rm -rf "\(dest)"
mv "\(staged)" "\(dest)"
xattr -cr "\(dest)"
# Relaunch
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()
}
// 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: - 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()
}
}
}