Files
shannoncoat/Sources/UpdateChecker.swift
Claude Opus 5andbdeshi c6a7da54ca Show profile paths, report the build version, and colour each profile
- Selecting a row in the Profiles tab shows its Claude Desktop and Claude
  Code dirs inline and selectable, instead of requiring a right-click
  "Reveal in Finder" to find out where a profile actually lives.
- build.sh stamps Contents/Resources/COMMIT with the short HEAD SHA, left
  empty when HEAD sits exactly on a tag (how a real release is built),
  since the version number alone is unambiguous there. About appends it
  when present, so two dev builds off the same VERSION are
  distinguishable. The update checker still compares plain semver.
- Profile dot colours are picked per NSApp.effectiveAppearance at render
  time: same hue either way, but full saturation with brightness split
  per background (1.0 dark, 0.55 light), so they read as bold hues rather
  than washing out on white or muddying against a dark menu.
2026-08-04 22:44:21 +06:00

106 lines
4.7 KiB
Swift

// Deliberately minimal: fetch the raw VERSION file from `main` on GitHub
// and compare semver to the bundled version. No silent binary-replacing
// auto-update — that needs signing/notarization/an appcast server this
// project doesn't have. A manual check shows an alert (an explicit action
// that expects a response); the periodic automatic check instead posts a
// quiet system notification, since that one wasn't asked for in the
// moment.
import Foundation
import UserNotifications
enum UpdateChecker {
enum Outcome {
case upToDate
case updateAvailable(version: String, url: URL)
case failed
}
private static let versionURL = URL(string: "https://raw.githubusercontent.com/bdeshi/shannoncoat/main/VERSION")!
private static let releaseURL = URL(string: "https://github.com/bdeshi/shannoncoat/releases/latest")!
private static let enabledDefaultsKey = "AutomaticUpdateCheckEnabled"
private static let lastCheckDefaultsKey = "LastUpdateCheckDate"
private static let checkInterval: TimeInterval = 24 * 60 * 60
static var automaticCheckEnabled: Bool {
get { UserDefaults.standard.object(forKey: enabledDefaultsKey) as? Bool ?? true }
set { UserDefaults.standard.set(newValue, forKey: enabledDefaultsKey) }
}
static var currentVersion: String {
guard let resourceURL = Bundle.main.resourceURL,
let contents = try? String(contentsOf: resourceURL.appendingPathComponent("VERSION"), encoding: .utf8)
else { return "unknown" }
return contents.trimmingCharacters(in: .whitespacesAndNewlines)
}
// What the About panel shows: the version alone for a real release
// build (COMMIT is written empty when build.sh finds HEAD sitting
// exactly on a tag), or version + short SHA for a dev build, so two
// local builds off the same version number are distinguishable.
static var displayVersion: String {
commitSHA.isEmpty ? currentVersion : "\(currentVersion) (\(commitSHA))"
}
private static var commitSHA: String {
guard let resourceURL = Bundle.main.resourceURL,
let contents = try? String(contentsOf: resourceURL.appendingPathComponent("COMMIT"), encoding: .utf8)
else { return "" }
return contents.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func checkManually(completion: @escaping (Outcome) -> Void) {
fetchLatestVersion { latest in
guard let latest else { completion(.failed); return }
if isNewer(latest, than: currentVersion) {
completion(.updateAvailable(version: latest, url: releaseURL))
} else {
completion(.upToDate)
}
}
}
// Called once at launch; a no-op unless the toggle is on and it's been
// at least a day since the last check.
static func checkAutomaticallyIfDue() {
guard automaticCheckEnabled else { return }
let last = UserDefaults.standard.object(forKey: lastCheckDefaultsKey) as? Date ?? .distantPast
guard Date().timeIntervalSince(last) > checkInterval else { return }
UserDefaults.standard.set(Date(), forKey: lastCheckDefaultsKey)
fetchLatestVersion { latest in
guard let latest, isNewer(latest, than: currentVersion) else { return }
postAvailableNotification(version: latest)
}
}
private static func fetchLatestVersion(completion: @escaping (String?) -> Void) {
URLSession.shared.dataTask(with: versionURL) { data, _, _ in
let version = data.flatMap { String(data: $0, encoding: .utf8) }?
.trimmingCharacters(in: .whitespacesAndNewlines)
completion((version?.isEmpty == false) ? version : nil)
}.resume()
}
private static func isNewer(_ a: String, than b: String) -> Bool {
let av = a.split(separator: ".").compactMap { Int($0) }
let bv = b.split(separator: ".").compactMap { Int($0) }
for i in 0..<max(av.count, bv.count) {
let x = i < av.count ? av[i] : 0
let y = i < bv.count ? bv[i] : 0
if x != y { return x > y }
}
return false
}
private static func postAvailableNotification(version: String) {
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert]) { granted, _ in
guard granted else { return }
let content = UNMutableNotificationContent()
content.title = "shannoncoat \(version) is available"
content.body = "Click to view the release notes."
center.add(UNNotificationRequest(identifier: "shannoncoat-update-available", content: content, trigger: nil))
}
}
}