Every profile launches the same Claude Desktop binary with the same icon and title, so with two or more open there was no way to tell which window belonged to which profile at a glance. WindowOverlay pins a small tag — profile name, in that profile's own dot colour — to each running managed profile's window, tracked live via AXObserver notifications rather than polling. Only a persistent process can hold a watcher like that, which is exactly the gap the old one-shot CLI could never close. Shown for every running managed profile, not just when two or more are up: the tag is an identity marker, not merely a disambiguator. Never shown on "default", which isn't a shannoncoat profile at all. Positioned top-right, since top-left is where a window's close/minimize/ zoom controls live. Draggable in case that still obstructs something in a particular layout, on a .nonactivatingPanel so dragging it doesn't steal focus from the window underneath, with the offset persisted. Attachment retries for ~3s rather than requiring the target's first window to already exist: a cold Electron launch can take a second or more between the process starting — which is what triggers the update, via NSWorkspace's launch notification — and its first window existing. Losing that race previously meant the tag silently never appeared at all. Also ignores CHECKPOINT.md, a local session-handoff note.
311 lines
14 KiB
Swift
311 lines
14 KiB
Swift
// 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
|
|
|
|
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<String> = []
|
|
|
|
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() }
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|