// 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 // What clicking a profile in the menu does. The two actions are always both // reachable — whichever one isn't on the plain click is on Shift-click — so // this is a swap, not an on/off. enum MenuClickAction: String { case switchTo case openAlongside // Names the action generically, for the menu's two hint lines — the // per-profile tooltips say what it does to that profile as things // actually stand (see AppDelegate.outcome). var phrase: String { switch self { case .switchTo: return "switch (quits the others)" case .openAlongside: return "open alongside" } } } // One setting for the whole app, stored as the *plain* click's action; the // Shift-click action is derived, which keeps the two from ever being set to // the same thing. enum MenuClickBehavior { private static let plainClickKey = "MenuPlainClickAction" static var plainClick: MenuClickAction { get { UserDefaults.standard.string(forKey: plainClickKey) .flatMap(MenuClickAction.init(rawValue:)) ?? .switchTo } set { UserDefaults.standard.set(newValue.rawValue, forKey: plainClickKey) } } static var shiftClick: MenuClickAction { plainClick == .switchTo ? .openAlongside : .switchTo } } // Whether the startup picker (see AppDelegate.showStartupPicker) should // appear at all when nothing's running at launch. Off by default, so the // existing picker keeps showing up unless someone opts out of it. enum StartupBehavior { private static let hidePickerKey = "HideStartupPicker" static var hidePicker: Bool { get { UserDefaults.standard.bool(forKey: hidePickerKey) } set { UserDefaults.standard.set(newValue, forKey: hidePickerKey) } } } // A person who's opted out of the startup picker still needs to discover // the menu bar icon somehow the very first time — this fires once, ever, // the first time that situation actually arises, then never again. private enum MenuBarHintShown { private static let key = "MenuBarHintShown" static var value: Bool { get { UserDefaults.standard.bool(forKey: key) } set { UserDefaults.standard.set(newValue, forKey: key) } } } 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? private var appearanceObserver: NSKeyValueObservation? private var menuBarHintPopover: NSPopover? func applicationDidFinishLaunching(_ notification: Notification) { menu.delegate = self menu.autoenablesItems = false statusItem.menu = menu statusItem.button?.image = MenuBarIcon.image() 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() } // The glyph is a template image and re-tints itself, but the coloured // dots beside it don't: ProfileColor picks a brightness per current // appearance at the moment it's asked, and the title is only rebuilt // when profiles start or stop. Without this, switching to dark mode // leaves dots mixed for that appearance sitting in the menu bar until // something unrelated happens to redraw them. appearanceObserver = NSApp.observe(\.effectiveAppearance) { [weak self] _, _ in DispatchQueue.main.async { [weak self] in guard let self else { return } self.applyTitle(self.currentInfos) ManageWindowController.shared.update(self.currentInfos) } } 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, unless // that picker's been turned off, in which case a one-time hint // takes its place (see MenuBarHintShown). if currentInfos.filter(\.running).isEmpty { if StartupBehavior.hidePicker { showMenuBarHintIfNeeded() } else { showStartupPicker(currentInfos) } } UpdateChecker.checkAutomaticallyIfDue() } // The menu bar draws its own glyph (see MenuBarIcon) rather than the app // icon scaled down, which is what it used to do: at 18pt the app icon's // three coats collapse into a smudge, and its colours ignore the menu // bar's appearance entirely. MenuBarIcon returns a template image, so // macOS tints it for light/dark and for the open-menu inversion, and // re-tints it by itself when the theme changes. // 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()) // Read once per rebuild rather than per item. The menu is rebuilt // from scratch on every open, so changing the setting is reflected // the next time the menu is shown without anything having to tell // the delegate about it. let plainClick = MenuClickBehavior.plainClick let shiftClick = MenuClickBehavior.shiftClick // Both gestures are spelled out, not just the modified one: which is // which is now a setting, so a single line about Shift would leave the // plain click to be assumed — and it's the plain click that quits // things when it's the one bound to switching. for (gesture, action) in [("Click", plainClick), ("\u{21e7}-click", shiftClick)] { let hint = NSMenuItem(title: "\(gesture) to \(action.phrase)", action: nil, keyEquivalent: "") hint.isEnabled = false menu.addItem(hint) } menu.addItem(.separator()) for info in profiles { // One click switches (quitting every other running profile), the // other opens alongside without disturbing what's running — which // is on the plain click and which is on Shift is the user's // choice, see MenuClickBehavior. 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 = "Click to \(outcome(of: plainClick, for: info))" + " • \u{21e7}-click to \(outcome(of: shiftClick, for: info))" 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) } // What a gesture will actually do to THIS profile as things stand — the // same action reads differently depending on what's up. Switching only // quits neighbours when there are neighbours to quit, and opening // alongside is only "alongside" something when something else is running. private func outcome(of action: MenuClickAction, for info: ProfileInfo) -> String { let others = currentInfos.filter { $0.running && $0.profile.name != info.profile.name }.count let reach = info.running ? "focus it" : "start it" switch action { case .openAlongside: return others == 0 ? reach : "\(reach), leaving the others running" case .switchTo: guard others > 0 else { return reach } return "\(reach), quitting the other \(others == 1 ? "profile" : "\(others) profiles")" } } // 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() } } // Stands in for showStartupPicker when that's been turned off — but // only the very first time nothing's running at launch, ever. After // that, someone who opted out of the picker gets silence, same as // launching with something already running. private func showMenuBarHintIfNeeded() { guard !MenuBarHintShown.value, let button = statusItem.button else { return } MenuBarHintShown.value = true let label = NSTextField(wrappingLabelWithString: "Claude isn't running — click here to start a profile.") label.font = .systemFont(ofSize: 13) label.isEditable = false label.isBordered = false label.drawsBackground = false label.preferredMaxLayoutWidth = 200 let container = NSView() container.addSubview(label) label.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ label.topAnchor.constraint(equalTo: container.topAnchor, constant: 12), label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -12), label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 14), label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -14), ]) let viewController = NSViewController() viewController.view = container let popover = NSPopover() popover.contentViewController = viewController popover.behavior = .transient menuBarHintPopover = popover popover.show(relativeTo: button.bounds, of: button, preferredEdge: .maxY) } // 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) let shiftHeld = NSApp.currentEvent?.modifierFlags.contains(.shift) == true switch shiftHeld ? MenuClickBehavior.shiftClick : MenuClickBehavior.plainClick { case .openAlongside: openProfile(profile) case .switchTo: 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() } } } // Switching means "make this the one that's running", so it quits the // others whether or not this profile is already up. // // It used to bail out early for an already-running profile, on the // reasoning that a click reaching for a second open window is someone // moving between windows rather than asking for one to go. That guess is // exactly what MenuClickBehavior now settles: both actions have a gesture // of their own, so choosing the switch gesture *is* the instruction, and // second-guessing it left no way to ask for an exclusive switch at all // once the target was up. 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) } }