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.
241 lines
9.7 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|