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.
181 lines
7.8 KiB
Swift
181 lines
7.8 KiB
Swift
// 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)
|
|
}
|
|
}
|