Files
Claude Opus 5andbdeshi 135db9b308 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.
2026-08-04 21:56:22 +06:00

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)
}
}