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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user