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.
87 lines
3.5 KiB
Swift
87 lines
3.5 KiB
Swift
// Enumerates running Claude processes and their --user-data-dir, without
|
|
// spawning pgrep (avoids both the subprocess-per-check overhead and an
|
|
// assumption that /usr/bin/pgrep exists at a fixed path). Uses the same
|
|
// sysctl(KERN_PROC_ALL)/sysctl(KERN_PROCARGS2) technique ps/pgrep use
|
|
// internally for same-user processes — no extra entitlement needed.
|
|
//
|
|
// Called on demand from live-state notification handlers (see AppDelegate),
|
|
// never on a timer.
|
|
import Darwin
|
|
import Foundation
|
|
|
|
struct RunningClaude {
|
|
let pid: pid_t
|
|
// nil means launched with no --user-data-dir flag at all (i.e. the
|
|
// "default" profile).
|
|
let userDataDir: String?
|
|
}
|
|
|
|
enum ProcessInspector {
|
|
static func listRunningClaude(binaryPath: String) -> [RunningClaude] {
|
|
allPIDs().compactMap { pid in
|
|
guard let info = execInfo(pid: pid), info.execPath == binaryPath else { return nil }
|
|
let flagPrefix = "--user-data-dir="
|
|
let dir = info.args.first(where: { $0.hasPrefix(flagPrefix) }).map { String($0.dropFirst(flagPrefix.count)) }
|
|
return RunningClaude(pid: pid, userDataDir: dir)
|
|
}
|
|
}
|
|
|
|
static func pid(forUserDataDir dir: String?, binaryPath: String) -> pid_t? {
|
|
listRunningClaude(binaryPath: binaryPath).first { $0.userDataDir == dir }?.pid
|
|
}
|
|
|
|
private static func allPIDs() -> [pid_t] {
|
|
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0]
|
|
var size = 0
|
|
guard sysctl(&mib, 4, nil, &size, nil, 0) == 0, size > 0 else { return [] }
|
|
|
|
let capacity = size / MemoryLayout<kinfo_proc>.stride + 1
|
|
var procs = [kinfo_proc](repeating: kinfo_proc(), count: capacity)
|
|
var actualSize = capacity * MemoryLayout<kinfo_proc>.stride
|
|
guard sysctl(&mib, 4, &procs, &actualSize, nil, 0) == 0 else { return [] }
|
|
|
|
let actualCount = actualSize / MemoryLayout<kinfo_proc>.stride
|
|
return procs[0..<actualCount].map { $0.kp_proc.p_pid }
|
|
}
|
|
|
|
private struct ExecInfo { let execPath: String; let args: [String] }
|
|
|
|
// KERN_PROCARGS2 buffer layout: argc (Int32), then the exec path
|
|
// (NUL-terminated, followed by NUL padding), then argv[0..<argc] each
|
|
// NUL-terminated, then envp (which we don't read). Same layout `ps`
|
|
// itself parses.
|
|
private static func execInfo(pid: pid_t) -> ExecInfo? {
|
|
var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, pid]
|
|
var size = 0
|
|
guard sysctl(&mib, 3, nil, &size, nil, 0) == 0, size > MemoryLayout<Int32>.size else { return nil }
|
|
|
|
var buffer = [UInt8](repeating: 0, count: size)
|
|
guard sysctl(&mib, 3, &buffer, &size, nil, 0) == 0 else { return nil }
|
|
|
|
var argc = Int32(0)
|
|
withUnsafeMutableBytes(of: &argc) { dst in
|
|
dst.copyBytes(from: buffer[0..<MemoryLayout<Int32>.size])
|
|
}
|
|
|
|
var offset = MemoryLayout<Int32>.size
|
|
func readCString() -> String? {
|
|
guard offset < size else { return nil }
|
|
let start = offset
|
|
while offset < size, buffer[offset] != 0 { offset += 1 }
|
|
guard offset > start else { return nil }
|
|
let string = String(decoding: buffer[start..<offset], as: UTF8.self)
|
|
while offset < size, buffer[offset] == 0 { offset += 1 } // skip padding NULs
|
|
return string
|
|
}
|
|
|
|
guard let execPath = readCString() else { return nil }
|
|
var args: [String] = []
|
|
var i: Int32 = 0
|
|
while i < argc, let arg = readCString() {
|
|
args.append(arg)
|
|
i += 1
|
|
}
|
|
return ExecInfo(execPath: execPath, args: args)
|
|
}
|
|
}
|