pommedoro/Sources/Pommedoro/AutoUpdater.swift

247 lines
9.9 KiB
Swift

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