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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
// Keeps the menu bar in sync with reality without ever polling. Registers
|
||||
// NSWorkspace notifications for launch/terminate/activate/hide/unhide,
|
||||
// filtered to Claude's own binary, and reconciles an in-memory
|
||||
// [ProfileInfo] the instant one fires (sub-second, push-based) — this is
|
||||
// what makes external changes (Force Quit, Spotlight, Dock, a hand quit)
|
||||
// show up immediately instead of needing a menu click or an app restart.
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
struct ProfileInfo {
|
||||
let profile: ResolvedProfile
|
||||
let running: Bool
|
||||
let pid: pid_t?
|
||||
}
|
||||
|
||||
protocol LiveStateDelegate: AnyObject {
|
||||
func liveStateDidChange(_ infos: [ProfileInfo])
|
||||
func liveStateFoundCollisions(_ collisions: [ProfileStore.DirCollision])
|
||||
}
|
||||
|
||||
final class LiveState {
|
||||
weak var delegate: LiveStateDelegate?
|
||||
|
||||
// Best-effort "last switched to" pointer for the ⇆ marker in the menu —
|
||||
// reflects profiles opened/switched through this app; there's no
|
||||
// ordered history to fall back through if that one gets hand-quit
|
||||
// (matches the "don't overdo it" brief — reality-vs-stopped state is
|
||||
// still always ground-truth, this only affects the ⇆ hint).
|
||||
private(set) var lastActiveName = "default"
|
||||
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
init() {
|
||||
let center = NSWorkspace.shared.notificationCenter
|
||||
let names: [Notification.Name] = [
|
||||
NSWorkspace.didLaunchApplicationNotification,
|
||||
NSWorkspace.didTerminateApplicationNotification,
|
||||
NSWorkspace.didActivateApplicationNotification,
|
||||
NSWorkspace.didHideApplicationNotification,
|
||||
NSWorkspace.didUnhideApplicationNotification,
|
||||
]
|
||||
observers = names.map { name in
|
||||
center.addObserver(forName: name, object: nil, queue: .main) { [weak self] note in
|
||||
self?.handle(note)
|
||||
}
|
||||
}
|
||||
// Deliberately not calling reconcile() here — `delegate` is set by
|
||||
// the caller right after init, and the caller does the first
|
||||
// reconcile itself once it's ready to receive the callback.
|
||||
}
|
||||
|
||||
deinit {
|
||||
let center = NSWorkspace.shared.notificationCenter
|
||||
observers.forEach { center.removeObserver($0) }
|
||||
}
|
||||
|
||||
private func handle(_ note: Notification) {
|
||||
guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
|
||||
app.executableURL?.path == ClaudeControl.binaryPath
|
||||
else { return }
|
||||
reconcile()
|
||||
}
|
||||
|
||||
func markActive(_ name: String) {
|
||||
lastActiveName = name
|
||||
}
|
||||
|
||||
// Public so callers (menu open, right after a command completes) can
|
||||
// force an immediate reconcile as a correctness backstop, without that
|
||||
// being the primary update mechanism.
|
||||
func reconcile() {
|
||||
let profiles = ProfileStore.loadAll()
|
||||
let collisions = ProfileStore.findCollisions(among: profiles)
|
||||
guard collisions.isEmpty else {
|
||||
delegate?.liveStateFoundCollisions(collisions)
|
||||
return
|
||||
}
|
||||
|
||||
let running = ClaudeControl.runningInstances()
|
||||
let infos = profiles.map { profile -> ProfileInfo in
|
||||
let dir = profile.name == "default" ? nil : profile.appDir
|
||||
let match = running.first { $0.userDataDir == dir }
|
||||
return ProfileInfo(profile: profile, running: match != nil, pid: match?.pid)
|
||||
}
|
||||
delegate?.liveStateDidChange(infos)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user