Files
shannoncoat/Sources/AppDelegate.swift
T
Claude Opus 5andbdeshi 5191a38f7a Fix the window tags: permission, drift, anchoring, and styling
Four separate defects, each reproduced before being fixed.

Accessibility was silently denied. AXIsProcessTrusted() returned false,
so every AX read failed, WindowOverlay.init? failed all twelve retries,
and attach gave up without a word — indistinguishable from the feature
being broken. The cause was outside this file: an ad-hoc-signed app has
its permission pinned to its exact cdhash, which changes on every build,
so the rebuild-and-replace workflow silently revoked the grant each time
while the app stayed listed and ticked in System Settings. Now it prompts
rather than failing mutely, and keeps rechecking — macOS never tells an
app it has just been granted Accessibility, and overlays were otherwise
only reconsidered when a Claude window launched, quit or activated, so a
grant made while running did nothing visible until the user happened to
touch a Claude window.

The tag walked off its window on every drag. windowDidMove re-derived and
persisted the corner offset for the app's own programmatic moves too, on
the assumption that recomputing an offset it had just positioned against
was a no-op. It isn't: AppKit posts that notification synchronously from
inside setFrame, and during a live drag the AX frame read there is
already newer than the one reposition used, so each event banked the few
points the window had moved in between. Observed saturating at 952,
which parks the tag at the far edge of the window or off-screen.

Placement was anchored to the top-right unconditionally, so any resize
moving that corner dragged the tag along. It now anchors to whichever
corner it was dropped nearest. Offsets are no longer written back
clamped either: a window too small to honour one shows the tag pushed in
as far as it fits but keeps the stored distance, so widening the window
restores the chosen spot.

Styling: thinner (13pt) and pill-shaped so it can sit close to an edge
without covering window controls; rotated when against a side edge,
reading bottom-to-top on the left and top-to-bottom on the right so the
text leans into the window; text colour chosen per profile colour by WCAG
relative luminance rather than fixed white, which was as low as ~1.1:1 on
a yellow chip against 4.50:1 worst-case now; and text drawn into a
one-line-tall rect centred on the chip's midline, fixing its high
seating. Settings gains a choice between the name chip and a plain
coloured dot, which is never rotated, having no reading direction.

Verified live throughout: a 400pt offset squeezed to 260 on a narrowed
window and returned to 400 on restore with the stored value untouched;
a drag committed the expected corner and orientation; and contrast was
measured across the hue wheel.

Tag placement is also stored per profile rather than once for the whole
app. Two windows side by side are the case these tags exist for, and
wanting each one's tag somewhere different is the normal outcome — one
window's sidebar is not another's. Previously a single shared offset made
them look independent (only the dragged tag moved at once) while every
other tag snapped to it on its next reposition.

A tag is a floating panel, which puts it above every ordinary window on
the system rather than merely above the window it labels — so it hovered
over the browser, the editor, and everything else, even when its own
window was buried or on another Space. There is no cross-process way to
attach one window above another (addChildWindow is same-process only, and
the private ordering call window managers use is a one-shot that goes
stale on the next reorder), so each tag now checks whether the window it
labels is genuinely visible beneath it. The window server returns its list
front-to-back, so one pass answers it: anything overlapping the tag before
we reach our own window is covering it, and never reaching that window
means it isn't on screen at all.

Tag position is also clamped into whatever part of the window is on
screen. A window dragged half off the edge takes its anchored corner with
it, and a tag that follows it out of view identifies nothing. The clamp is
presentational only — the stored anchor is untouched, so the tag returns
to it once the window is fully back.

Occlusion is judged from the window server's listing, which it returns
front-to-back. Our own window is identified by its owning process rather
than by matching rectangles: AX reports a new position the instant a
window moves while the listing still holds the previous one, so a
geometric match fails almost continuously mid-drag — measured at 87 of 92
frames, during which the walk ran past our own window and mistook
whatever else overlapped the tag for something covering it.

Only ordinary windows count as occluders. Everything above that band is
permanently in front and would veto the tag forever — including the
invisible one-pixel markers some utilities park in a screen corner, which
is what made a tag vanish at the bottom-left and nowhere else: it takes a
window moved off two edges at once for the tag to clamp into that pixel.
2026-08-05 13:08:11 +06:00

398 lines
18 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
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?
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() }
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() }
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)
}
// 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())
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)
}
}