bump
This commit is contained in:
parent
fa34565417
commit
0eadd6b26b
|
|
@ -61,9 +61,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
NotificationManager.shared.requestPermission()
|
NotificationManager.shared.requestPermission()
|
||||||
NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60)
|
NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60)
|
||||||
|
|
||||||
// Check for updates silently on launch
|
|
||||||
AutoUpdater.checkInBackground(silent: true)
|
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self, selector: #selector(settingsDidChange),
|
self, selector: #selector(settingsDidChange),
|
||||||
name: Settings.didChangeNotification, object: nil
|
name: Settings.didChangeNotification, object: nil
|
||||||
|
|
@ -185,8 +182,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@objc func checkForUpdates() {
|
@objc func updateLocal() {
|
||||||
AutoUpdater.checkInBackground(silent: false)
|
AutoUpdater.updateLocal(silent: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func closePommedoro() {
|
@objc func closePommedoro() {
|
||||||
|
|
@ -230,7 +227,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
@objc func dismissSuggestion() {
|
@objc func dismissSuggestion() {
|
||||||
currentSuggestionFeedback = .dismissed
|
currentSuggestionFeedback = .dismissed
|
||||||
SuggestionManager.shared.dismiss(suggestion: currentSuggestion)
|
SuggestionManager.shared.dismiss(suggestion: currentSuggestion)
|
||||||
resumePommedoro()
|
reflectionDone()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Work Log Helpers
|
// MARK: - Work Log Helpers
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,12 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import AppKit
|
import AppKit
|
||||||
import CommonCrypto
|
|
||||||
|
|
||||||
/// Self-update by comparing local state (SHA + timestamp) to remote SHA + Last-Modified
|
/// Update Local: downloads DMG from remote and swaps app. No version or integrity checks.
|
||||||
/// from the git server over HTTPS (no login). Update only when server is newer.
|
|
||||||
enum AutoUpdater {
|
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(
|
private static let remoteDMGURL = URL(
|
||||||
string: "https://git.nixc.us/colin/pommedoro/raw/branch/main/releases/Pommedoro.dmg"
|
string: "https://git.nixc.us/colin/pommedoro/raw/branch/main/releases/Pommedoro.dmg"
|
||||||
)!
|
)!
|
||||||
/// First 64 hex chars (lowercased); tolerates BOM/CRLF/trailing bytes from server.
|
|
||||||
private static func normalizeSHA(_ s: String) -> String? {
|
|
||||||
let hex = s.lowercased().filter { "0123456789abcdef".contains($0) }
|
|
||||||
return hex.count >= 64 ? String(hex.prefix(64)) : nil
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private static var supportDir: URL {
|
private static var supportDir: URL {
|
||||||
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
|
|
@ -34,147 +22,36 @@ enum AutoUpdater {
|
||||||
private static var stagedAppPath: String { NSTemporaryDirectory() + "Pommedoro-staged.app" }
|
private static var stagedAppPath: String { NSTemporaryDirectory() + "Pommedoro-staged.app" }
|
||||||
private static var pendingStatePath: String { NSTemporaryDirectory() + "pommedoro-pending-state.txt" }
|
private static var pendingStatePath: String { NSTemporaryDirectory() + "pommedoro-pending-state.txt" }
|
||||||
|
|
||||||
static func checkInBackground(silent: Bool = true) {
|
static func updateLocal(silent: Bool = false) {
|
||||||
DispatchQueue.global(qos: .utility).async { performCheck(silent: silent) }
|
DispatchQueue.global(qos: .utility).async { performUpdateLocal(silent: silent) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func performCheck(silent: Bool) {
|
private static func performUpdateLocal(silent: Bool) {
|
||||||
if !silent { showAlert(title: "Check for Updates", message: "Auto-update is disabled.") }
|
if !silent { showAlert(title: "Update Local", message: "Getting latest version...") }
|
||||||
if false {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
let remoteSHA = Self.normalizeSHA(remote.sha) ?? remote.sha
|
|
||||||
let localSHA = local?.sha == devSentinel ? local?.sha : (local.flatMap { Self.normalizeSHA($0.sha) })
|
|
||||||
if remoteSHA == localSHA {
|
|
||||||
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)
|
let sem = DispatchSemaphore(value: 0)
|
||||||
var out: (sha: String, timestamp: TimeInterval)?
|
var downloadOk = false
|
||||||
|
|
||||||
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 raw = String(data: data, encoding: .utf8),
|
|
||||||
let sha = Self.normalizeSHA(raw) 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
|
let task = URLSession.shared.downloadTask(with: remoteDMGURL) { url, response, _ in
|
||||||
defer { sem.signal() }
|
defer { sem.signal() }
|
||||||
guard let url = url, (response as? HTTPURLResponse)?.statusCode == 200 else { return }
|
guard let url = url, (response as? HTTPURLResponse)?.statusCode == 200 else { return }
|
||||||
do {
|
do {
|
||||||
try? FileManager.default.removeItem(at: downloadedDMGPath)
|
try? FileManager.default.removeItem(at: downloadedDMGPath)
|
||||||
try FileManager.default.moveItem(at: url, to: downloadedDMGPath)
|
try FileManager.default.moveItem(at: url, to: downloadedDMGPath)
|
||||||
ok = true
|
downloadOk = true
|
||||||
} catch { BugReporting.capture(error) }
|
} catch {}
|
||||||
}
|
}
|
||||||
task.resume()
|
task.resume()
|
||||||
sem.wait()
|
sem.wait()
|
||||||
return ok
|
guard downloadOk else {
|
||||||
}
|
if !silent { showAlert(title: "Update Failed", message: "Download error.") }
|
||||||
|
return
|
||||||
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 first = lines.first else { return nil }
|
|
||||||
let trimmed = first.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let sha: String
|
|
||||||
if trimmed == devSentinel {
|
|
||||||
sha = devSentinel
|
|
||||||
} else if let normalized = Self.normalizeSHA(first) {
|
|
||||||
sha = normalized
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
let ts: TimeInterval = lines.count > 1 ? (TimeInterval(lines[1]) ?? 0) : 0
|
guard stageFromDMG() else {
|
||||||
return (sha, ts)
|
if !silent { showAlert(title: "Update Failed", message: "Install error.") }
|
||||||
}
|
return
|
||||||
|
}
|
||||||
static func writeLocalSHA(_ sha: String) {
|
try? "local\n0".write(toFile: pendingStatePath, atomically: true, encoding: .utf8)
|
||||||
try? sha.write(to: statePath, atomically: true, encoding: .utf8)
|
spawnSwapAndRelaunch()
|
||||||
|
DispatchQueue.main.async { NSApp.terminate(nil) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func stageFromDMG() -> Bool {
|
private static func stageFromDMG() -> Bool {
|
||||||
|
|
@ -189,11 +66,10 @@ enum AutoUpdater {
|
||||||
guard FileManager.default.fileExists(atPath: srcApp) else { return false }
|
guard FileManager.default.fileExists(atPath: srcApp) else { return false }
|
||||||
try? FileManager.default.removeItem(atPath: stagedAppPath)
|
try? FileManager.default.removeItem(atPath: stagedAppPath)
|
||||||
do { try FileManager.default.copyItem(atPath: srcApp, toPath: stagedAppPath) }
|
do { try FileManager.default.copyItem(atPath: srcApp, toPath: stagedAppPath) }
|
||||||
catch { BugReporting.capture(error); return false }
|
catch { return false }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Script: wait for process exit, swap app, write state from pending file, relaunch.
|
|
||||||
private static func spawnSwapAndRelaunch() {
|
private static func spawnSwapAndRelaunch() {
|
||||||
let pid = ProcessInfo.processInfo.processIdentifier
|
let pid = ProcessInfo.processInfo.processIdentifier
|
||||||
let staged = stagedAppPath
|
let staged = stagedAppPath
|
||||||
|
|
@ -236,23 +112,6 @@ enum AutoUpdater {
|
||||||
return proc.terminationStatus == 0
|
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) {
|
private static func showAlert(title: String, message: String) {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
let a = NSAlert()
|
let a = NSAlert()
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ extension AppDelegate {
|
||||||
|
|
||||||
@objc func skipSuggestion() {
|
@objc func skipSuggestion() {
|
||||||
currentSuggestionFeedback = .skipped
|
currentSuggestionFeedback = .skipped
|
||||||
resumePommedoro()
|
reflectionDone()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func didSuccess() {
|
@objc func didSuccess() {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ extension AppDelegate {
|
||||||
launchAtLoginMenuItem.state = LaunchAgent.isInstalled() ? .on : .off
|
launchAtLoginMenuItem.state = LaunchAgent.isInstalled() ? .on : .off
|
||||||
menu.addItem(launchAtLoginMenuItem)
|
menu.addItem(launchAtLoginMenuItem)
|
||||||
menu.addItem(NSMenuItem.separator())
|
menu.addItem(NSMenuItem.separator())
|
||||||
menu.addItem(NSMenuItem(title: "Check for Updates…", action: #selector(checkForUpdates), keyEquivalent: "u"))
|
menu.addItem(NSMenuItem(title: "Update Local", action: #selector(updateLocal), keyEquivalent: "u"))
|
||||||
menu.addItem(NSMenuItem(title: "Quit Pommedoro", action: #selector(closePommedoro), keyEquivalent: "q"))
|
menu.addItem(NSMenuItem(title: "Quit Pommedoro", action: #selector(closePommedoro), keyEquivalent: "q"))
|
||||||
|
|
||||||
for item in menu.items {
|
for item in menu.items {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue