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.
88 lines
3.4 KiB
Swift
88 lines
3.4 KiB
Swift
// 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)
|
|
}
|
|
}
|