- Selecting a row in the Profiles tab shows its Claude Desktop and Claude Code dirs inline and selectable, instead of requiring a right-click "Reveal in Finder" to find out where a profile actually lives. - build.sh stamps Contents/Resources/COMMIT with the short HEAD SHA, left empty when HEAD sits exactly on a tag (how a real release is built), since the version number alone is unambiguous there. About appends it when present, so two dev builds off the same VERSION are distinguishable. The update checker still compares plain semver. - Profile dot colours are picked per NSApp.effectiveAppearance at render time: same hue either way, but full saturation with brightness split per background (1.0 dark, 0.55 light), so they read as bold hues rather than washing out on white or muddying against a dark menu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
293 lines
13 KiB
Swift
293 lines
13 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
|
|
|
|
private let author = "bdeshi"
|
|
private let homepage = "https://github.com/bdeshi/shannoncoat"
|
|
|
|
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
|
|
|
|
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)
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
@objc func showAbout() {
|
|
// Accessory apps (no Dock icon) don't auto-activate for a window
|
|
// they open, so the standard About panel would appear behind
|
|
// whatever's frontmost without this.
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
let credits = """
|
|
Which coat will Claude wear today? Menu bar switcher for running multiple isolated Claude Desktop profiles side by side — each paired with its own Claude Code config so the two never cross-contaminate.
|
|
|
|
Author: \(author)
|
|
Source: \(homepage)
|
|
|
|
Version: \(UpdateChecker.displayVersion)
|
|
"""
|
|
NSApp.orderFrontStandardAboutPanel(options: [
|
|
.applicationName: "shannoncoat",
|
|
.credits: NSAttributedString(string: credits),
|
|
])
|
|
}
|
|
|
|
@objc func quitClaude() {
|
|
ClaudeControl.quitAll { DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } }
|
|
}
|
|
|
|
@objc func quitSelf() {
|
|
NSApp.terminate(nil)
|
|
}
|
|
}
|