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