diff --git a/Resources/install.command b/Resources/install.command index 2dca2fa..d09056a 100755 --- a/Resources/install.command +++ b/Resources/install.command @@ -8,6 +8,7 @@ APP_NAME="Pommedoro" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" APP_SRC="${SCRIPT_DIR}/${APP_NAME}.app" APP_DST="/Applications/${APP_NAME}.app" +REMOTE_SHA_URL="https://git.nixc.us/colin/pommedoro/raw/branch/main/releases/Pommedoro.dmg.sha256" echo "" echo " Installing ${APP_NAME}..." @@ -32,13 +33,29 @@ cp -R "${APP_SRC}" "${APP_DST}" # Strip quarantine attribute (fixes "app is damaged" on downloaded DMGs) xattr -cr "${APP_DST}" -# Stamp SHA256 so auto-updater knows current version +# Stamp state (SHA + timestamp) for auto-updater via HTTPS, no login SHA_DIR="${HOME}/Library/Application Support/Pommedoro" mkdir -p "${SHA_DIR}" -# Find the source .dmg backing this mounted volume +OUR_SHA="" DMG_DEVICE="$(hdiutil info 2>/dev/null | grep -B 20 "${SCRIPT_DIR}" | grep 'image-path' | awk -F' : ' '{print $2}' | head -1)" if [ -n "${DMG_DEVICE}" ] && [ -f "${DMG_DEVICE}" ]; then - shasum -a 256 "${DMG_DEVICE}" | awk '{print $1}' > "${SHA_DIR}/current.sha256" + OUR_SHA="$(shasum -a 256 "${DMG_DEVICE}" | awk '{print $1}')" +fi + +REMOTE_SHA="" +REMOTE_TS="0" +if [ -n "${OUR_SHA}" ]; then + REMOTE_SHA="$(curl -sL "${REMOTE_SHA_URL}" 2>/dev/null | head -1 | tr -d '\r')" + LAST_MOD="$(curl -sIL "${REMOTE_SHA_URL}" 2>/dev/null | grep -i '^Last-Modified:' | head -1 | sed 's/^Last-Modified: *//' | tr -d '\r')" + if [ -n "${LAST_MOD}" ]; then + REMOTE_TS="$(date -j -f "%a, %d %b %Y %H:%M:%S %Z" "${LAST_MOD}" "+%s" 2>/dev/null || echo "0")" + fi + # If this DMG matches remote, use remote timestamp; else use 0 so app may prompt later + if [ "${OUR_SHA}" = "${REMOTE_SHA}" ] && [ -n "${REMOTE_SHA}" ]; then + printf "%s\n%s\n" "${OUR_SHA}" "${REMOTE_TS}" > "${SHA_DIR}/current.sha256" + else + printf "%s\n0\n" "${OUR_SHA}" > "${SHA_DIR}/current.sha256" + fi echo " SHA256 stamped for auto-update." fi diff --git a/Sources/Pommedoro/AutoUpdater.swift b/Sources/Pommedoro/AutoUpdater.swift index c40ccda..af6b8a8 100644 --- a/Sources/Pommedoro/AutoUpdater.swift +++ b/Sources/Pommedoro/AutoUpdater.swift @@ -2,253 +2,200 @@ 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. +/// 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 { - // 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 { + 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.appendingPathComponent("current.sha256") + return dir } - /// Temporary download location for the DMG + 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" } - /// 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) - } + DispatchQueue.global(qos: .utility).async { 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.") - } + let local = readLocalState() + if local?.sha == 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 { + guard let remote = fetchRemoteRelease() else { if !silent { showAlert(title: "Update Check Failed", message: "Could not reach the update server.") } return } - // 4. Compare - if remoteSHA == localSHA { + let localTimestamp = local?.timestamp ?? 0 + if remote.timestamp <= localTimestamp { + if !silent { showAlert(title: "Up to Date", message: "You are running the latest version.") } + return + } + if remote.sha == local?.sha { 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 promptForUpdate() else { return } 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 { + 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 } - - // 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 + try? "\(remote.sha)\n\(Int(remote.timestamp))".write(toFile: pendingStatePath, atomically: true, encoding: .utf8) spawnSwapAndRelaunch() - DispatchQueue.main.async { - NSApp.terminate(nil) - } + DispatchQueue.main.async { NSApp.terminate(nil) } } - // MARK: - Networking + // MARK: - Fetch from git server (HTTPS, no login) - private static func fetchRemoteSHA() -> String? { + private static func fetchRemoteRelease() -> (sha: String, timestamp: TimeInterval)? { let sem = DispatchSemaphore(value: 0) - var result: String? + 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 str = String(data: data, encoding: .utf8) else { return } - result = str.trimmingCharacters(in: .whitespacesAndNewlines) + 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 result + 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 success = false - + var ok = 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 } + guard let url = url, (response as? HTTPURLResponse)?.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) - } + 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 success + return ok } - // MARK: - SHA256 Computation - - static func sha256(of fileURL: URL) -> String? { - guard let handle = try? FileHandle(forReadingFrom: fileURL) else { return nil } + 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) // 1 MB + let chunk = handle.readData(ofLength: 1_048_576) if chunk.isEmpty { return false } - chunk.withUnsafeBytes { ptr in - _ = CC_SHA256_Update(&ctx, ptr.baseAddress, CC_LONG(chunk.count)) - } + 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 SHA + // MARK: - Local state (SHA + timestamp, two lines) - private static func readLocalSHA() -> String? { - guard let str = try? String(contentsOf: localSHAPath, encoding: .utf8) else { return nil } - return str.trimmingCharacters(in: .whitespacesAndNewlines) + 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) } - /// 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) + try? sha.write(to: statePath, 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 } - + 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 } - - // Copy to staging try? FileManager.default.removeItem(atPath: stagedAppPath) - do { - try FileManager.default.copyItem(atPath: srcApp, toPath: stagedAppPath) - } catch { - BugReporting.capture(error) - return false - } - + 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. + /// 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 - # 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 + 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)" - # Relaunch + 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] @@ -258,33 +205,24 @@ enum AutoUpdater { 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 - } + guard (try? proc.run()) != nil else { return false } + proc.waitUntilExit() + return proc.terminationStatus == 0 } - // 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.informativeText = "A new version of Pommedoro is available. Update now?" alert.alertStyle = .informational alert.addButton(withTitle: "Update") alert.addButton(withTitle: "Later") @@ -297,12 +235,12 @@ enum AutoUpdater { 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() + let a = NSAlert() + a.messageText = title + a.informativeText = message + a.alertStyle = .informational + a.addButton(withTitle: "OK") + a.runModal() } } } diff --git a/build-test.sh b/build-test.sh index 944a985..7e5e401 100755 --- a/build-test.sh +++ b/build-test.sh @@ -62,15 +62,21 @@ else echo "==> OK: no quarantine attribute." fi -echo "==> Verifying SHA256 was stamped locally..." +echo "==> Verifying state (SHA + timestamp) was stamped locally..." SHA_FILE="${HOME}/Library/Application Support/Pommedoro/current.sha256" if [ ! -f "${SHA_FILE}" ]; then - echo "==> FAIL: local SHA256 stamp not found at ${SHA_FILE}." + echo "==> FAIL: local state file not found at ${SHA_FILE}." hdiutil detach /Volumes/Pommedoro -quiet 2>/dev/null || true exit 1 fi -LOCAL_SHA="$(cat "${SHA_FILE}")" -echo "==> OK: local SHA256 stamp = ${LOCAL_SHA}" +LOCAL_SHA="$(head -1 "${SHA_FILE}")" +EXPECTED_SHA="$(cat releases/Pommedoro.dmg.sha256)" +if [ "${LOCAL_SHA}" != "${EXPECTED_SHA}" ]; then + echo "==> FAIL: local SHA ${LOCAL_SHA} != expected ${EXPECTED_SHA}." + hdiutil detach /Volumes/Pommedoro -quiet 2>/dev/null || true + exit 1 +fi +echo "==> OK: local state SHA = ${LOCAL_SHA}" hdiutil detach /Volumes/Pommedoro -quiet 2>/dev/null || true diff --git a/releases/Pommedoro.dmg b/releases/Pommedoro.dmg index 446b2d0..4c59aa4 100644 Binary files a/releases/Pommedoro.dmg and b/releases/Pommedoro.dmg differ diff --git a/releases/Pommedoro.dmg.sha256 b/releases/Pommedoro.dmg.sha256 index 7f97de6..11dae90 100644 --- a/releases/Pommedoro.dmg.sha256 +++ b/releases/Pommedoro.dmg.sha256 @@ -1 +1 @@ -1990cd4168e219d731b10e7e61b374a671666f5486875b4f839a133f29eed068 +1030ff4f33acbe9694bb9260bde5b1871e9ad42af3c7813f7ac99164f2335c2e