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,86 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user