// 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) } } } }