Files
shannoncoat/Sources/ClaudeControl.swift
T
Claude Opus 5andbdeshi 135db9b308 Rewrite shannoncoat as a native Swift menu-bar app
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.
2026-08-04 21:56:22 +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)
}
private 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)
}
}
}
}