diff --git a/Makefile b/Makefile index 434fba8..2fa1153 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ RESOURCES_DIR = $(CONTENTS)/Resources LAUNCH_AGENT_LABEL = com.pommedoro.app LAUNCH_AGENT_PLIST = $(HOME)/Library/LaunchAgents/$(LAUNCH_AGENT_LABEL).plist -.PHONY: all build bundle install clean run icons install-agent uninstall-agent dmg +.PHONY: all build bundle install clean run icons install-agent uninstall-agent dmg release all: bundle @@ -103,3 +103,11 @@ dmg: bundle clean: swift package clean rm -rf $(APP_BUNDLE) + +release: dmg + @echo "Creating release artifacts..." + @mkdir -p releases + @cp $(BUILD_DIR)/$(APP_NAME).dmg releases/$(APP_NAME).dmg + @shasum -a 256 releases/$(APP_NAME).dmg | awk '{print $$1}' > releases/$(APP_NAME).dmg.sha256 + @echo "SHA256: $$(cat releases/$(APP_NAME).dmg.sha256)" + @echo "Release artifacts in releases/" diff --git a/Resources/install.command b/Resources/install.command index 12e9e67..2dca2fa 100755 --- a/Resources/install.command +++ b/Resources/install.command @@ -32,6 +32,16 @@ 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 +SHA_DIR="${HOME}/Library/Application Support/Pommedoro" +mkdir -p "${SHA_DIR}" +# Find the source .dmg backing this mounted volume +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" + echo " SHA256 stamped for auto-update." +fi + echo " Installed to ${APP_DST}" echo " Launching ${APP_NAME}..." echo "" diff --git a/Sources/Pommedoro/AppDelegate.swift b/Sources/Pommedoro/AppDelegate.swift index b0e5d3b..fdfbda8 100644 --- a/Sources/Pommedoro/AppDelegate.swift +++ b/Sources/Pommedoro/AppDelegate.swift @@ -61,6 +61,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { NotificationManager.shared.requestPermission() NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60) + // Check for updates silently on launch + AutoUpdater.checkInBackground(silent: true) + NotificationCenter.default.addObserver( self, selector: #selector(settingsDidChange), name: Settings.didChangeNotification, object: nil @@ -181,6 +184,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + + @objc func checkForUpdates() { + AutoUpdater.checkInBackground(silent: false) + } + @objc func closePommedoro() { saveCurrentInterval() countdownTimer?.invalidate() diff --git a/Sources/Pommedoro/AutoUpdater.swift b/Sources/Pommedoro/AutoUpdater.swift new file mode 100644 index 0000000..3cfd374 --- /dev/null +++ b/Sources/Pommedoro/AutoUpdater.swift @@ -0,0 +1,264 @@ +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() + } + } +} diff --git a/Sources/Pommedoro/BreakScreens.swift b/Sources/Pommedoro/BreakScreens.swift index 986444d..920fecd 100644 --- a/Sources/Pommedoro/BreakScreens.swift +++ b/Sources/Pommedoro/BreakScreens.swift @@ -96,7 +96,7 @@ extension AppDelegate { closeBtn.bezelStyle = .rounded closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium) closeBtn.target = self - closeBtn.action = #selector(showWorkLog) + closeBtn.action = #selector(resumePommedoro) view.addSubview(closeBtn) let continueWorkingBtn = NSButton(frame: NSRect(x: (size.width - 200) / 2, y: size.height / 2 - 50, width: 200, height: 50)) @@ -198,7 +198,7 @@ extension AppDelegate { closeBtn.bezelStyle = .rounded closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium) closeBtn.target = self - closeBtn.action = #selector(showWorkLog) + closeBtn.action = #selector(resumePommedoro) view.addSubview(closeBtn) let reflY = size.height / 2 - 60 @@ -282,7 +282,7 @@ extension AppDelegate { closeBtn.bezelStyle = .rounded closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium) closeBtn.target = self - closeBtn.action = #selector(showWorkLog) + closeBtn.action = #selector(resumePommedoro) view.addSubview(closeBtn) // Reflection text field diff --git a/Sources/Pommedoro/StatusBar.swift b/Sources/Pommedoro/StatusBar.swift index de4fcc5..ed707d3 100644 --- a/Sources/Pommedoro/StatusBar.swift +++ b/Sources/Pommedoro/StatusBar.swift @@ -36,6 +36,7 @@ extension AppDelegate { launchAtLoginMenuItem.state = LaunchAgent.isInstalled() ? .on : .off menu.addItem(launchAtLoginMenuItem) menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: "Check for Updates…", action: #selector(checkForUpdates), keyEquivalent: "u")) menu.addItem(NSMenuItem(title: "Quit Pommedoro", action: #selector(closePommedoro), keyEquivalent: "q")) for item in menu.items { diff --git a/Sources/Pommedoro/WorkLog.swift b/Sources/Pommedoro/WorkLog.swift index 59c0394..c534a09 100644 --- a/Sources/Pommedoro/WorkLog.swift +++ b/Sources/Pommedoro/WorkLog.swift @@ -68,15 +68,46 @@ class WorkLogStore { private(set) var entries: [WorkLogEntry] = [] private static var workLogFileURL: URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Documents", isDirectory: true) - .appendingPathComponent("pommedoro", isDirectory: true) + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return support + .appendingPathComponent("Pommedoro", isDirectory: true) .appendingPathComponent("worklog.log", isDirectory: false) } private init() { load() clearOldEntries() + migrateFromDocumentsIfNeeded() + } + + /// One-time migration: move worklog.log from ~/Documents/pommedoro/ to Application Support + private func migrateFromDocumentsIfNeeded() { + let oldFile = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Documents", isDirectory: true) + .appendingPathComponent("pommedoro", isDirectory: true) + .appendingPathComponent("worklog.log", isDirectory: false) + guard FileManager.default.fileExists(atPath: oldFile.path) else { return } + + ensureWorkLogDirectory() + let newFile = Self.workLogFileURL + + // If new file doesn't exist yet, just move it; otherwise append old content + if !FileManager.default.fileExists(atPath: newFile.path) { + try? FileManager.default.moveItem(at: oldFile, to: newFile) + } else if let oldData = try? Data(contentsOf: oldFile), + let handle = try? FileHandle(forWritingTo: newFile) { + handle.seekToEndOfFile() + handle.write(oldData) + try? handle.close() + try? FileManager.default.removeItem(at: oldFile) + } + + // Clean up empty old directory + let oldDir = oldFile.deletingLastPathComponent() + let remaining = (try? FileManager.default.contentsOfDirectory(atPath: oldDir.path)) ?? [] + if remaining.isEmpty { + try? FileManager.default.removeItem(at: oldDir) + } } func addEntry(_ entry: WorkLogEntry) {