Lets someone skip the startup picker entirely; the first time it would've shown with nothing running, a small popover on the menu bar icon points there instead, then never again. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
553 lines
26 KiB
Swift
553 lines
26 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
|
||
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<String> = []
|
||
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)
|
||
}
|
||
}
|