Files
shannoncoat/Sources/ProcessInspector.swift
Claude Opus 5andbdeshi 135db9b308 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.
2026-08-04 21:56:22 +06:00

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