309 lines
11 KiB
Swift
309 lines
11 KiB
Swift
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()
|
||
}
|
||
}
|
||
}
|