// Menu bar behavior: title dots, per-profile menu, Manage Profiles window. // Reads live state from LiveState (push-based, no polling) and calls // ProfileStore/ClaudeControl in-process — no subprocess, no text parsing. import AppKit import ApplicationServices final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveStateDelegate { let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) let menu = NSMenu() let liveState = LiveState() private var currentInfos: [ProfileInfo] = [] private var collisionAlertShown = false private var windowOverlays: [String: WindowOverlay] = [:] private var pendingWindowOverlays: Set = [] private var accessibilityPromptShown = false private var accessibilityRecheckScheduled = false private var activationObserver: NSObjectProtocol? func applicationDidFinishLaunching(_ notification: Notification) { menu.delegate = self menu.autoenablesItems = false statusItem.menu = menu statusItem.button?.image = menuBarIcon() statusItem.button?.imagePosition = .imageLeft liveState.delegate = self ManageWindowController.shared.onProfilesChanged = { [weak self] in self?.liveState.reconcile() } ManageWindowController.shared.onOverlayStyleChanged = { [weak self] in self?.rebuildWindowOverlays() } // Every app activation, not just Claude's — LiveState deliberately // filters to the Claude binary, so on its own it never hears that // some unrelated app came forward, which is exactly when the tags // need to get out of the way. activationObserver = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main ) { [weak self] _ in self?.updateOverlayVisibility() } liveState.reconcile() // Launching this app fresh with nothing running means there's no // menu-bar title/dots to click yet — offer a picker up front // instead of leaving the user to discover the dropdown. if currentInfos.filter(\.running).isEmpty { showStartupPicker(currentInfos) } UpdateChecker.checkAutomaticallyIfDue() } // Claude's own app icon (bundled as Contents/Resources/AppIcon.icns — // currently a placeholder, see icon/generate-icon.swift), scaled down // for the menu bar. private func menuBarIcon() -> NSImage { let source = NSApp.applicationIconImage ?? NSImage(named: NSImage.applicationIconName) ?? NSImage() let resized = NSImage(size: NSSize(width: 18, height: 18)) resized.lockFocus() source.draw(in: NSRect(x: 0, y: 0, width: 18, height: 18), from: .zero, operation: .sourceOver, fraction: 1.0) resized.unlockFocus() return resized } // MARK: - LiveStateDelegate func liveStateDidChange(_ infos: [ProfileInfo]) { collisionAlertShown = false currentInfos = infos applyTitle(infos) ManageWindowController.shared.update(infos) updateWindowOverlays(infos) } // Each tag decides for itself whether the window it labels is actually // visible underneath it (see WindowOverlay.isTargetVisible) — this just // says "now would be a good time to look again". Activation is the // usual way a window gets buried or uncovered without moving at all, // which no AX notification reports. private func updateOverlayVisibility() { for overlay in windowOverlays.values { overlay.refreshVisibility() } } // macOS doesn't notify an app that it has just been granted // Accessibility, and overlays are otherwise only reconsidered when a // Claude window launches, quits or activates — so a permission granted // while this is running would do nothing visible until the user // happened to touch a Claude window, or restarted the app. Polling for // it costs a cheap local check every couple of seconds, and only while // the permission is actually missing: the moment it lands, the guard in // `updateWindowOverlays` passes and this stops rescheduling itself. // // Also covers a subtler case — replacing the installed bundle and // relaunching immediately can start the app before TCC has settled on // the new copy, and a one-shot check there would strand the tags for // the rest of the session over a denial that was only ever momentary. private func scheduleAccessibilityRecheck() { guard !accessibilityRecheckScheduled else { return } accessibilityRecheckScheduled = true DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in guard let self else { return } accessibilityRecheckScheduled = false updateWindowOverlays(currentInfos) } } // A style change rewrites the tag's shape and size, which is decided // when its panel is built — so the existing ones are dropped (deinit // tears down each panel and its AXObserver) and rebuilt rather than // patched in place. private func rebuildWindowOverlays() { windowOverlays.removeAll() updateWindowOverlays(currentInfos) } // Shown on every running *managed* profile — never on "default", // which isn't a shannoncoat profile at all, just the real underlying // Claude install. Removing an entry here deinits its WindowOverlay, // which tears down its AXObserver and hides the tag panel. private func updateWindowOverlays(_ infos: [ProfileInfo]) { let managed = infos.filter { $0.running && $0.profile.name != "default" } let managedNames = Set(managed.map(\.profile.name)) windowOverlays = windowOverlays.filter { managedNames.contains($0.key) } pendingWindowOverlays.formIntersection(managedNames) // A tag is positioned entirely from Accessibility data, so without // that permission there is nothing to position: `attach` would // burn its dozen retries per profile and give up without a word, // which reads as "the feature is broken" rather than "macOS said // no". Prompt instead — the same call `ClaudeControl.focus` makes // for the same reason. // // Worth knowing when this bites, because it looks impossible: // a local build gets a fresh code identity every time build.sh // runs, and that silently invalidates an existing grant while // leaving the app still listed *and still ticked* under Privacy & // Security → Accessibility. Re-granting means removing that stale // row with "−" and adding the newly built app back; toggling it // off and on again is not enough. (Note that running the binary // straight out of the bundle from a terminal will appear to work // regardless — it inherits the terminal's grant, not the app's, // so it's useless for testing this.) if !managed.isEmpty, !AXIsProcessTrusted() { if !accessibilityPromptShown { accessibilityPromptShown = true ClaudeControl.promptForAccessibility() } scheduleAccessibilityRecheck() return } for info in managed where windowOverlays[info.profile.name] == nil && !pendingWindowOverlays.contains(info.profile.name) { guard let pid = info.pid else { continue } let name = info.profile.name pendingWindowOverlays.insert(name) WindowOverlay.attach(profileName: name, color: ProfileColor.dotColor(for: name), pid: pid) { [weak self] overlay in guard let self else { return } pendingWindowOverlays.remove(name) // The profile may have quit again while this was retrying // for its window — don't attach a stale overlay if so. guard currentInfos.contains(where: { $0.profile.name == name && $0.running }) else { return } windowOverlays[name] = overlay // A tag created while some other app is frontmost must not // appear over it — `attach` can complete seconds after the // launch that triggered it, by which point focus has often // moved on. updateOverlayVisibility() } } updateOverlayVisibility() } // Two profiles sharing a dir is a fatal misconfiguration (would // silently merge their Claude sessions) — surfaced with an alert // rather than crashing the app outright, since that would leave the // user with no way to fix it short of hand-editing files blind. func liveStateFoundCollisions(_ collisions: [ProfileStore.DirCollision]) { guard !collisionAlertShown else { return } collisionAlertShown = true let lines = collisions.map { "\u{2022} \($0.kind): \"\($0.profileA)\" and \"\($0.profileB)\" both use \($0.dir)" } let alert = NSAlert() alert.alertStyle = .critical alert.messageText = "shannoncoat: configuration error" alert.informativeText = "Two profiles can't share a directory:\n\n" + lines.joined(separator: "\n") alert.addButton(withTitle: "OK") alert.runModal() } // Title reflects what's ACTUALLY running — one profile shows as a dot // + name; several show as dots only, to keep the menu bar from growing // unbounded, with the full list available via tooltip and the dropdown. private func applyTitle(_ profiles: [ProfileInfo]) { guard let button = statusItem.button else { return } let running = profiles.filter(\.running) switch running.count { case 0: button.attributedTitle = NSAttributedString(string: "") button.toolTip = "Claude not running" case 1: let name = running[0].profile.name let title = NSMutableAttributedString( string: "\u{25cf} ", attributes: [.foregroundColor: ProfileColor.dotColor(for: name)]) title.append(NSAttributedString(string: name, attributes: [.foregroundColor: NSColor.labelColor])) button.attributedTitle = title button.toolTip = nil default: let title = NSMutableAttributedString() for info in running { title.append(NSAttributedString( string: "\u{25cf}", attributes: [.foregroundColor: ProfileColor.dotColor(for: info.profile.name)])) } button.attributedTitle = title button.toolTip = running.map(\.profile.name).joined(separator: ", ") } } // MARK: - Menu func menuNeedsUpdate(_ menu: NSMenu) { menu.removeAllItems() liveState.reconcile() // correctness backstop; live updates are the primary mechanism let profiles = currentInfos let runningCount = profiles.filter(\.running).count let header = NSMenuItem( title: runningCount == 0 ? "Claude not running" : runningCount == 1 ? "1 profile running" : "\(runningCount) profiles running", action: nil, keyEquivalent: "") header.isEnabled = false menu.addItem(header) menu.addItem(.separator()) let hint = NSMenuItem(title: "\u{21e7}-click to open alongside", action: nil, keyEquivalent: "") hint.isEnabled = false menu.addItem(hint) menu.addItem(.separator()) for info in profiles { // Click = exclusive switch (quits every other running profile). // Shift-click = open alongside instead, without disturbing // anything else that's running. let title = info.profile.name == liveState.lastActiveName ? "\(info.profile.name) \u{21c6}" : info.profile.name let item = NSMenuItem(title: title, action: #selector(pick(_:)), keyEquivalent: "") item.target = self item.representedObject = info.profile.name item.image = ProfileColor.dotImage(for: info.profile.name, dimmed: !info.running) item.state = info.running ? .on : .off item.toolTip = info.running ? "Running — click to focus" : "Click to switch • Shift-click to open alongside" menu.addItem(item) } menu.addItem(.separator()) let manageItem = NSMenuItem( title: "Manage Profiles\u{2026}", action: #selector(openManageWindow), keyEquivalent: ",") manageItem.keyEquivalentModifierMask = [.command] manageItem.target = self menu.addItem(manageItem) let closeItem = NSMenuItem(title: "Close Profile", action: nil, keyEquivalent: "") let closeMenu = NSMenu() let closeable = profiles.filter(\.running) if closeable.isEmpty { let none = NSMenuItem(title: "(none running)", action: nil, keyEquivalent: "") none.isEnabled = false closeMenu.addItem(none) } else { for info in closeable { let mi = NSMenuItem(title: info.profile.name, action: #selector(closeProfile(_:)), keyEquivalent: "") mi.target = self mi.representedObject = info.profile.name closeMenu.addItem(mi) } } closeItem.submenu = closeMenu menu.addItem(closeItem) menu.addItem(.separator()) let aboutItem = NSMenuItem(title: "About shannoncoat", action: #selector(showAbout), keyEquivalent: "") aboutItem.target = self menu.addItem(aboutItem) menu.addItem(.separator()) let quitClaudeItem = NSMenuItem(title: "Quit Claude", action: #selector(quitClaude), keyEquivalent: "") quitClaudeItem.target = self menu.addItem(quitClaudeItem) let quitSelfItem = NSMenuItem(title: "Quit shannoncoat", action: #selector(quitSelf), keyEquivalent: "q") quitSelfItem.target = self menu.addItem(quitSelfItem) } // Shown once at launch when nothing is running. A checklist rather // than a single pick: unlike the menu's click/Shift-click (one // profile, exclusive vs. alongside), there's nothing running yet to be // "alongside" of, so this lets several profiles be selected to start // together in one go. private func showStartupPicker(_ profiles: [ProfileInfo]) { guard !profiles.isEmpty else { return } let alert = NSAlert() alert.messageText = "Start Claude" alert.informativeText = "Nothing is running. Choose which profiles to start:" alert.addButton(withTitle: "Start") alert.addButton(withTitle: "Cancel") let rowHeight: CGFloat = 22 let container = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: rowHeight * CGFloat(profiles.count))) var checkboxes: [(box: NSButton, name: String)] = [] for (i, info) in profiles.enumerated() { let y = rowHeight * CGFloat(profiles.count - 1 - i) let box = NSButton(checkboxWithTitle: info.profile.name, target: nil, action: nil) box.frame = NSRect(x: 0, y: y, width: 240, height: rowHeight) box.state = info.profile.name == liveState.lastActiveName ? .on : .off container.addSubview(box) checkboxes.append((box, info.profile.name)) } alert.accessoryView = container guard alert.runModal() == .alertFirstButtonReturn else { return } for (box, name) in checkboxes where box.state == .on { guard let profile = profiles.first(where: { $0.profile.name == name })?.profile else { continue } liveState.markActive(name) ClaudeControl.launch(profile) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.liveState.reconcile() } } // MARK: - Actions @objc func pick(_ sender: NSMenuItem) { guard let name = sender.representedObject as? String, let profile = currentInfos.first(where: { $0.profile.name == name })?.profile else { return } liveState.markActive(name) if NSApp.currentEvent?.modifierFlags.contains(.shift) == true { openProfile(profile) } else { switchTo(profile) } } private func openProfile(_ profile: ResolvedProfile) { if let pid = ClaudeControl.pid(for: profile) { ClaudeControl.focus(pid: pid) { _ in DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } } } else { ClaudeControl.launch(profile) // The launch notification usually beats this, but a short // explicit poke keeps the UI snappy even if it doesn't. DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.liveState.reconcile() } } } private func switchTo(_ profile: ResolvedProfile) { let others = currentInfos.filter { $0.running && $0.profile.name != profile.name } guard !others.isEmpty else { openProfile(profile); return } var remaining = others.count for info in others { guard let pid = info.pid else { remaining -= 1; continue } ClaudeControl.quit(pid: pid) { [weak self] _ in remaining -= 1 if remaining == 0 { DispatchQueue.main.async { self?.openProfile(profile) } } } } } @objc func closeProfile(_ sender: NSMenuItem) { guard let name = sender.representedObject as? String, let info = currentInfos.first(where: { $0.profile.name == name }), let pid = info.pid else { return } ClaudeControl.quit(pid: pid) { _ in DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } } } @objc func openManageWindow() { ManageWindowController.shared.update(currentInfos) ManageWindowController.shared.show() } // About lives as a tab in the same window (see ManageWindow.swift) // rather than the standard NSApp About panel — the standard panel // auto-appends its own "Version X (Y)" line from Info.plist's // CFBundleVersion/CFBundleShortVersionString, which are identical in // this build, so it was showing that duplicated alongside our own // version line. @objc func showAbout() { ManageWindowController.shared.update(currentInfos) ManageWindowController.shared.show(tab: .about) } @objc func quitClaude() { ClaudeControl.quitAll { DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } } } @objc func quitSelf() { NSApp.terminate(nil) } }