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,180 @@
|
||||
// Profile config storage: one ~/.shannoncoat/<name>.json per profile,
|
||||
// holding the two dirs a profile needs — Claude Desktop's --user-data-dir,
|
||||
// and the CLAUDE_CONFIG_DIR paired with it so any Claude Code session
|
||||
// launched from that Desktop instance gets its own isolated config too,
|
||||
// instead of sharing one with every other profile. (Claude Code already
|
||||
// has its own profile mechanism for use outside Desktop — this isn't a
|
||||
// second one, just making sure a managed Desktop session doesn't leak
|
||||
// into it.) "default" is implicit — no file for it — and always means the
|
||||
// real, untouched ~/.claude + Application Support/Claude.
|
||||
import Foundation
|
||||
|
||||
struct ResolvedProfile: Equatable {
|
||||
let name: String
|
||||
let codeDir: String
|
||||
let appDir: String
|
||||
}
|
||||
|
||||
enum ProfileError: LocalizedError {
|
||||
case emptyName
|
||||
case reservedName
|
||||
case invalidName(String)
|
||||
case alreadyExists(String)
|
||||
case notFound(String)
|
||||
case dirCollision(existingProfile: String, dir: String, kind: String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyName:
|
||||
return "Name is required."
|
||||
case .reservedName:
|
||||
return "\"default\" is reserved for the real, untouched Claude install."
|
||||
case .invalidName(let name):
|
||||
return "\"\(name)\" isn't a valid profile name (no spaces, slashes, colons, or leading dots)."
|
||||
case .alreadyExists(let name):
|
||||
return "A profile named \"\(name)\" already exists."
|
||||
case .notFound(let name):
|
||||
return "No profile named \"\(name)\"."
|
||||
case .dirCollision(let existingProfile, let dir, let kind):
|
||||
return "\(kind) dir \(dir) is already used by profile \"\(existingProfile)\"."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What lives on disk. Profile name comes from the filename, not a field in
|
||||
// here, so there's only one place a profile's name can disagree with
|
||||
// itself. `paths` is nested (matching the old YAML's shape) rather than
|
||||
// flattened, so a later profile-specific feature has an obvious place to
|
||||
// add its own top-level key alongside `paths` without disturbing this one.
|
||||
private struct ProfileFile: Codable {
|
||||
var paths: Paths
|
||||
|
||||
struct Paths: Codable {
|
||||
var code: String
|
||||
var desktop: String
|
||||
}
|
||||
}
|
||||
|
||||
enum ProfileStore {
|
||||
static let root: URL = {
|
||||
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".shannoncoat")
|
||||
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
return url
|
||||
}()
|
||||
|
||||
static let defaultProfile = ResolvedProfile(
|
||||
name: "default",
|
||||
codeDir: FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".claude").path,
|
||||
appDir: FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support/Claude").path
|
||||
)
|
||||
|
||||
// "~" expands to home; anything else non-absolute resolves relative to
|
||||
// `root` (the config dir itself) rather than the process's cwd; an
|
||||
// already-absolute path is left alone.
|
||||
static func expand(_ raw: String) -> String {
|
||||
if raw.hasPrefix("~") {
|
||||
return (raw as NSString).expandingTildeInPath
|
||||
}
|
||||
if raw.hasPrefix("/") {
|
||||
return raw
|
||||
}
|
||||
return root.appendingPathComponent(raw).path
|
||||
}
|
||||
|
||||
static func validateName(_ name: String) throws {
|
||||
if name.isEmpty { throw ProfileError.emptyName }
|
||||
if name == "default" { throw ProfileError.reservedName }
|
||||
let hasBadChar = name.contains("/") || name.contains(":") || name.hasPrefix(".")
|
||||
|| name.rangeOfCharacter(from: .whitespacesAndNewlines) != nil
|
||||
if hasBadChar { throw ProfileError.invalidName(name) }
|
||||
}
|
||||
|
||||
private static func configURL(for name: String) -> URL {
|
||||
root.appendingPathComponent("\(name).json")
|
||||
}
|
||||
|
||||
// Every configured profile plus the implicit `default`, in no
|
||||
// particular order. Config files that fail to decode are skipped
|
||||
// rather than surfaced here — a hand-edited-into-garbage file
|
||||
// shouldn't take the whole menu down.
|
||||
static func loadAll() -> [ResolvedProfile] {
|
||||
var profiles = [defaultProfile]
|
||||
let files = (try? FileManager.default.contentsOfDirectory(
|
||||
at: root, includingPropertiesForKeys: nil)) ?? []
|
||||
for file in files where file.pathExtension == "json" {
|
||||
guard let data = try? Data(contentsOf: file),
|
||||
let profileFile = try? JSONDecoder().decode(ProfileFile.self, from: data)
|
||||
else { continue }
|
||||
let name = file.deletingPathExtension().lastPathComponent
|
||||
profiles.append(ResolvedProfile(
|
||||
name: name, codeDir: expand(profileFile.paths.code), appDir: expand(profileFile.paths.desktop)))
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
struct DirCollision { let profileA: String; let profileB: String; let dir: String; let kind: String }
|
||||
|
||||
// Two profiles secretly sharing a dir would silently merge their Claude
|
||||
// sessions — defeats the entire point of switching. Checked on every
|
||||
// load (see `loadAll` call sites), not just when a profile is created,
|
||||
// so a hand-edited file can't sneak one in unnoticed.
|
||||
static func findCollisions(among profiles: [ResolvedProfile]) -> [DirCollision] {
|
||||
var collisions: [DirCollision] = []
|
||||
for i in profiles.indices {
|
||||
for j in profiles.index(after: i)..<profiles.endIndex {
|
||||
if profiles[i].codeDir == profiles[j].codeDir {
|
||||
collisions.append(DirCollision(
|
||||
profileA: profiles[i].name, profileB: profiles[j].name,
|
||||
dir: profiles[i].codeDir, kind: "Claude Code"))
|
||||
}
|
||||
if profiles[i].appDir == profiles[j].appDir {
|
||||
collisions.append(DirCollision(
|
||||
profileA: profiles[i].name, profileB: profiles[j].name,
|
||||
dir: profiles[i].appDir, kind: "Claude Desktop"))
|
||||
}
|
||||
}
|
||||
}
|
||||
return collisions
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func create(name: String, codeDir: String?, appDir: String?) throws -> ResolvedProfile {
|
||||
try validateName(name)
|
||||
let url = configURL(for: name)
|
||||
guard !FileManager.default.fileExists(atPath: url.path) else {
|
||||
throw ProfileError.alreadyExists(name)
|
||||
}
|
||||
|
||||
let rawCode = codeDir?.isEmpty == false ? codeDir! : "data/\(name)/code"
|
||||
let rawApp = appDir?.isEmpty == false ? appDir! : "data/\(name)/app"
|
||||
let resolvedCode = expand(rawCode)
|
||||
let resolvedApp = expand(rawApp)
|
||||
|
||||
for existing in loadAll() {
|
||||
if existing.codeDir == resolvedCode {
|
||||
throw ProfileError.dirCollision(existingProfile: existing.name, dir: resolvedCode, kind: "Claude Code")
|
||||
}
|
||||
if existing.appDir == resolvedApp {
|
||||
throw ProfileError.dirCollision(existingProfile: existing.name, dir: resolvedApp, kind: "Claude Desktop")
|
||||
}
|
||||
}
|
||||
|
||||
try FileManager.default.createDirectory(atPath: resolvedCode, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(atPath: resolvedApp, withIntermediateDirectories: true)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
||||
let data = try encoder.encode(ProfileFile(paths: .init(code: rawCode, desktop: rawApp)))
|
||||
try data.write(to: url)
|
||||
|
||||
return ResolvedProfile(name: name, codeDir: resolvedCode, appDir: resolvedApp)
|
||||
}
|
||||
|
||||
static func delete(_ name: String) throws {
|
||||
guard name != "default" else { throw ProfileError.reservedName }
|
||||
let url = configURL(for: name)
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { throw ProfileError.notFound(name) }
|
||||
try FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user