Files
shannoncoat/Sources/ClaudeControl.swift
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

241 lines
9.7 KiB
Swift

// Everything about controlling the Claude process itself: launch, focus,
// quit. Only two real primitives — launch/focus one profile ("open"), and
// quit one profile ("close"); "switch" (exclusive) is composed from these
// two in AppDelegate rather than being a third code path here.
import AppKit
import ApplicationServices
import Foundation
import UniformTypeIdentifiers
enum ClaudeControl {
private static let appPathKey = "ClaudeAppPath"
// Both places an app legitimately lives on macOS: the machine-wide
// /Applications and a per-user ~/Applications. Checked in that order,
// after any location the user has pointed us at themselves.
private static var candidatePaths: [String] {
[
"/Applications/Claude.app",
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Applications/Claude.app").path,
]
}
private static var cachedAppPath: String?
// Resolved once per launch, because this is consulted on every process
// enumeration — which happens on every workspace notification.
static var appPath: String {
if let cachedAppPath { return cachedAppPath }
let resolved = resolveAppPath()
cachedAppPath = resolved
return resolved
}
static var binaryPath: String { "\(appPath)/Contents/MacOS/Claude" }
static var isInstalled: Bool { FileManager.default.fileExists(atPath: binaryPath) }
private static func holdsClaude(_ path: String) -> Bool {
FileManager.default.fileExists(atPath: "\(path)/Contents/MacOS/Claude")
}
private static func resolveAppPath() -> String {
if let saved = UserDefaults.standard.string(forKey: appPathKey), holdsClaude(saved) {
return saved
}
if let found = candidatePaths.first(where: holdsClaude) {
return found
}
// Found nothing. Report against the standard location anyway, so
// anything that surfaces this path names somewhere meaningful
// rather than an empty string.
return candidatePaths[0]
}
// For an install in neither standard location. Modal by nature, but only
// ever reached from an explicit user action that can't proceed without
// an answer — better than a menu click that silently does nothing.
@discardableResult
static func promptForAppLocation() -> Bool {
let panel = NSOpenPanel()
panel.message = "Couldn't find Claude in Applications. Choose Claude.app to continue."
panel.prompt = "Choose"
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.allowedContentTypes = [.application]
panel.directoryURL = URL(fileURLWithPath: "/Applications")
NSApp.activate(ignoringOtherApps: true)
guard panel.runModal() == .OK, let url = panel.url else { return false }
guard holdsClaude(url.path) else {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "That doesn't look like Claude."
alert.informativeText = "\(url.lastPathComponent) doesn't contain a Claude executable."
alert.runModal()
return false
}
UserDefaults.standard.set(url.path, forKey: appPathKey)
cachedAppPath = url.path
return true
}
static func runningInstances() -> [RunningClaude] {
ProcessInspector.listRunningClaude(binaryPath: binaryPath)
}
static func pid(for profile: ResolvedProfile) -> pid_t? {
let dir = profile.name == "default" ? nil : profile.appDir
return ProcessInspector.pid(forUserDataDir: dir, binaryPath: binaryPath)
}
// MARK: - Launch
@discardableResult
static func launch(_ profile: ResolvedProfile) -> Bool {
// Ask once rather than fail silently: a launch that does nothing
// gives the user no way to work out that Claude simply isn't where
// this expected it to be.
if !isInstalled, !promptForAppLocation() { return false }
let process = Process()
process.executableURL = URL(fileURLWithPath: binaryPath)
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
if profile.name != "default" {
try? FileManager.default.createDirectory(atPath: profile.codeDir, withIntermediateDirectories: true)
try? FileManager.default.createDirectory(atPath: profile.appDir, withIntermediateDirectories: true)
var env = ProcessInfo.processInfo.environment
env["CLAUDE_CONFIG_DIR"] = profile.codeDir
process.environment = env
process.arguments = ["--user-data-dir=\(profile.appDir)"]
}
do {
try process.run()
return true
} catch {
return false
}
}
// MARK: - Focus
// Robust focus: unhide, un-minimize, raise across Spaces via the
// Accessibility API directly (replacing the old AppleScript/System
// Events call), then NSRunningApplication.activate() as a second push.
// Waits briefly for a window to exist if the process was just launched,
// rather than giving up immediately. Best-effort by design (a focus
// request shouldn't be able to crash or block the caller), but no
// longer silent about the one failure that's actually actionable:
// Accessibility not granted.
static func focus(_ profile: ResolvedProfile, completion: @escaping (Bool) -> Void) {
guard let pid = pid(for: profile) else { completion(false); return }
focus(pid: pid, completion: completion)
}
static func focus(pid: pid_t, completion: @escaping (Bool) -> Void) {
if !AXIsProcessTrusted() {
promptForAccessibility()
}
guard let app = NSRunningApplication(processIdentifier: pid) else { completion(false); return }
if app.isHidden { app.unhide() }
let axApp = AXUIElementCreateApplication(pid)
waitForWindow(axApp: axApp, attemptsRemaining: 12) { window in
if let window {
setMinimized(window, false)
AXUIElementPerformAction(window, kAXRaiseAction as CFString)
}
app.activate()
completion(window != nil)
}
}
private static func waitForWindow(
axApp: AXUIElement, attemptsRemaining: Int, completion: @escaping (AXUIElement?) -> Void
) {
if let window = firstWindow(of: axApp) {
completion(window)
} else if attemptsRemaining <= 0 {
completion(nil)
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
waitForWindow(axApp: axApp, attemptsRemaining: attemptsRemaining - 1, completion: completion)
}
}
}
private static func firstWindow(of axApp: AXUIElement) -> AXUIElement? {
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &value) == .success,
let windows = value as? [AXUIElement], let first = windows.first
else { return nil }
return first
}
private static func setMinimized(_ window: AXUIElement, _ minimized: Bool) {
AXUIElementSetAttributeValue(window, kAXMinimizedAttribute as CFString, minimized as CFTypeRef)
}
static func promptForAccessibility() {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(options)
}
// MARK: - Quit
enum QuitResult { case exited, blockedByOwnDialog, forceKilled, alreadyGone }
// Fixes a real bug in the old shell version: it waited a flat 5s then
// unconditionally SIGKILLed, which would kill Claude out from under its
// own "unsaved work" / "generation in progress" confirmation if it
// showed one. `.terminate()` is a request the target can legitimately
// delay or decline (it can return `.terminateCancel` from its own
// applicationShouldTerminate:), not a command it's forced to obey — so
// this waits much longer, and if Claude still has a window up at the
// end of that window (a sign it's showing its own dialog, or otherwise
// isn't done with something) it stops short of forceTerminate and
// reports that back instead of killing underneath it.
static func quit(pid: pid_t, completion: @escaping (QuitResult) -> Void) {
guard let app = NSRunningApplication(processIdentifier: pid) else { completion(.alreadyGone); return }
app.terminate()
pollForExit(app: app, attemptsRemaining: 40, completion: completion) // 40 * 0.25s = 10s grace period
}
static func quitAll(completion: @escaping () -> Void) {
let pids = runningInstances().map(\.pid)
guard !pids.isEmpty else { completion(); return }
var remaining = pids.count
for pid in pids {
quit(pid: pid) { _ in
remaining -= 1
if remaining == 0 { completion() }
}
}
}
private static func pollForExit(
app: NSRunningApplication, attemptsRemaining: Int, completion: @escaping (QuitResult) -> Void
) {
if app.isTerminated {
completion(.exited)
} else if attemptsRemaining <= 0 {
if firstWindow(of: AXUIElementCreateApplication(app.processIdentifier)) != nil {
completion(.blockedByOwnDialog)
} else {
app.forceTerminate()
completion(.forceKilled)
}
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
pollForExit(app: app, attemptsRemaining: attemptsRemaining - 1, completion: completion)
}
}
}
}