Replaces the original shannoncoat.sh entirely with in-process Swift. The script stopped earning its keep once the rest went native, and being a persistent process rather than a one-shot CLI is what later makes live window tracking possible at all. - ProfileStore: JSON config at ~/.shannoncoat/<name>.json (was hand-rolled YAML), with ~ expansion, paths relative to the config dir, and name/dir-collision validation in one place. - ProcessInspector: sysctl(KERN_PROC_ALL/KERN_PROCARGS2) enumeration instead of shelling out to pgrep. - ClaudeControl: launch via Process, focus via direct Accessibility calls (unhide, un-minimize, poll-for-window, AXRaise) instead of AppleScript, and a quit that respects Claude's own termination handling rather than unconditionally SIGKILLing after a flat 5s timeout. - LiveState: NSWorkspace notification-driven state with no polling timer, so the menu bar reflects reality within about a second — including changes made outside the app entirely. - ManageWindow: one non-modal window replacing what would otherwise be a string of separate popup alerts, with inline add/remove and directory pickers. - LaunchAtLogin / UpdateChecker: SMAppService login-item toggle, and a minimal VERSION-file update check that alerts on a manual check and posts a quiet notification on the automatic one. Also drops the standalone CLI in favour of GUI-only, and adds a placeholder app icon drawn from vector shapes rather than composited on top of Claude's own icon assets. Claude Desktop is located at runtime rather than assumed: a path the user picked previously, then /Applications, then a per-user ~/Applications install, each accepted only if it actually contains the executable. If none match, launching a profile asks the user to locate it once and remembers the answer — a launch that silently does nothing gives them no way to work out what's wrong.
91 lines
4.0 KiB
Swift
91 lines
4.0 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)
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
}
|