Rewrite shannoncoat as a native Swift menu-bar app
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.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user