Replaces the original shannoncoat.sh entirely with in-process Swift. The script stopped earning its keep once the rest went native, and being a persistent process rather than a one-shot CLI is what later makes live window tracking possible at all. - ProfileStore: JSON config at ~/.shannoncoat/<name>.json (was hand-rolled YAML), with ~ expansion, paths relative to the config dir, and name/dir-collision validation in one place. - ProcessInspector: sysctl(KERN_PROC_ALL/KERN_PROCARGS2) enumeration instead of shelling out to pgrep. - ClaudeControl: launch via Process, focus via direct Accessibility calls (unhide, un-minimize, poll-for-window, AXRaise) instead of AppleScript, and a quit that respects Claude's own termination handling rather than unconditionally SIGKILLing after a flat 5s timeout. - LiveState: NSWorkspace notification-driven state with no polling timer, so the menu bar reflects reality within about a second — including changes made outside the app entirely. - ManageWindow: one non-modal window replacing what would otherwise be a string of separate popup alerts, with inline add/remove and directory pickers. - LaunchAtLogin / UpdateChecker: SMAppService login-item toggle, and a minimal VERSION-file update check that alerts on a manual check and posts a quiet notification on the automatic one. Also drops the standalone CLI in favour of GUI-only, and adds a placeholder app icon drawn from vector shapes rather than composited on top of Claude's own icon assets. Claude Desktop is located at runtime rather than assumed: a path the user picked previously, then /Applications, then a per-user ~/Applications install, each accepted only if it actually contains the executable. If none match, launching a profile asks the user to locate it once and remembers the answer — a launch that silently does nothing gives them no way to work out what's wrong.
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.currentVersion)
|
|
"""
|
|
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)
|
|
}
|
|
}
|