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.
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
// Menu bar behavior: title dots, per-profile menu, Manage Profiles window.
|
||||
// Reads live state from LiveState (push-based, no polling) and calls
|
||||
// ProfileStore/ClaudeControl in-process — no subprocess, no text parsing.
|
||||
import AppKit
|
||||
|
||||
private let author = "bdeshi"
|
||||
private let homepage = "https://github.com/bdeshi/shannoncoat"
|
||||
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveStateDelegate {
|
||||
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
let menu = NSMenu()
|
||||
let liveState = LiveState()
|
||||
private var currentInfos: [ProfileInfo] = []
|
||||
private var collisionAlertShown = false
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
menu.delegate = self
|
||||
menu.autoenablesItems = false
|
||||
statusItem.menu = menu
|
||||
statusItem.button?.image = menuBarIcon()
|
||||
statusItem.button?.imagePosition = .imageLeft
|
||||
|
||||
liveState.delegate = self
|
||||
ManageWindowController.shared.onProfilesChanged = { [weak self] in self?.liveState.reconcile() }
|
||||
liveState.reconcile()
|
||||
|
||||
// Launching this app fresh with nothing running means there's no
|
||||
// menu-bar title/dots to click yet — offer a picker up front
|
||||
// instead of leaving the user to discover the dropdown.
|
||||
if currentInfos.filter(\.running).isEmpty {
|
||||
showStartupPicker(currentInfos)
|
||||
}
|
||||
|
||||
UpdateChecker.checkAutomaticallyIfDue()
|
||||
}
|
||||
|
||||
// Claude's own app icon (bundled as Contents/Resources/AppIcon.icns —
|
||||
// currently a placeholder, see icon/generate-icon.swift), scaled down
|
||||
// for the menu bar.
|
||||
private func menuBarIcon() -> NSImage {
|
||||
let source = NSApp.applicationIconImage ?? NSImage(named: NSImage.applicationIconName) ?? NSImage()
|
||||
let resized = NSImage(size: NSSize(width: 18, height: 18))
|
||||
resized.lockFocus()
|
||||
source.draw(in: NSRect(x: 0, y: 0, width: 18, height: 18), from: .zero, operation: .sourceOver, fraction: 1.0)
|
||||
resized.unlockFocus()
|
||||
return resized
|
||||
}
|
||||
|
||||
// MARK: - LiveStateDelegate
|
||||
|
||||
func liveStateDidChange(_ infos: [ProfileInfo]) {
|
||||
collisionAlertShown = false
|
||||
currentInfos = infos
|
||||
applyTitle(infos)
|
||||
ManageWindowController.shared.update(infos)
|
||||
}
|
||||
|
||||
// Two profiles sharing a dir is a fatal misconfiguration (would
|
||||
// silently merge their Claude sessions) — surfaced with an alert
|
||||
// rather than crashing the app outright, since that would leave the
|
||||
// user with no way to fix it short of hand-editing files blind.
|
||||
func liveStateFoundCollisions(_ collisions: [ProfileStore.DirCollision]) {
|
||||
guard !collisionAlertShown else { return }
|
||||
collisionAlertShown = true
|
||||
let lines = collisions.map { "\u{2022} \($0.kind): \"\($0.profileA)\" and \"\($0.profileB)\" both use \($0.dir)" }
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .critical
|
||||
alert.messageText = "shannoncoat: configuration error"
|
||||
alert.informativeText = "Two profiles can't share a directory:\n\n" + lines.joined(separator: "\n")
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.runModal()
|
||||
}
|
||||
|
||||
// Title reflects what's ACTUALLY running — one profile shows as a dot
|
||||
// + name; several show as dots only, to keep the menu bar from growing
|
||||
// unbounded, with the full list available via tooltip and the dropdown.
|
||||
private func applyTitle(_ profiles: [ProfileInfo]) {
|
||||
guard let button = statusItem.button else { return }
|
||||
let running = profiles.filter(\.running)
|
||||
switch running.count {
|
||||
case 0:
|
||||
button.attributedTitle = NSAttributedString(string: "")
|
||||
button.toolTip = "Claude not running"
|
||||
case 1:
|
||||
let name = running[0].profile.name
|
||||
let title = NSMutableAttributedString(
|
||||
string: "\u{25cf} ", attributes: [.foregroundColor: ProfileColor.dotColor(for: name)])
|
||||
title.append(NSAttributedString(string: name, attributes: [.foregroundColor: NSColor.labelColor]))
|
||||
button.attributedTitle = title
|
||||
button.toolTip = nil
|
||||
default:
|
||||
let title = NSMutableAttributedString()
|
||||
for info in running {
|
||||
title.append(NSAttributedString(
|
||||
string: "\u{25cf}", attributes: [.foregroundColor: ProfileColor.dotColor(for: info.profile.name)]))
|
||||
}
|
||||
button.attributedTitle = title
|
||||
button.toolTip = running.map(\.profile.name).joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Menu
|
||||
|
||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||
menu.removeAllItems()
|
||||
liveState.reconcile() // correctness backstop; live updates are the primary mechanism
|
||||
let profiles = currentInfos
|
||||
let runningCount = profiles.filter(\.running).count
|
||||
|
||||
let header = NSMenuItem(
|
||||
title: runningCount == 0 ? "Claude not running"
|
||||
: runningCount == 1 ? "1 profile running" : "\(runningCount) profiles running",
|
||||
action: nil, keyEquivalent: "")
|
||||
header.isEnabled = false
|
||||
menu.addItem(header)
|
||||
menu.addItem(.separator())
|
||||
|
||||
let hint = NSMenuItem(title: "\u{21e7}-click to open alongside", action: nil, keyEquivalent: "")
|
||||
hint.isEnabled = false
|
||||
menu.addItem(hint)
|
||||
menu.addItem(.separator())
|
||||
|
||||
for info in profiles {
|
||||
// Click = exclusive switch (quits every other running profile).
|
||||
// Shift-click = open alongside instead, without disturbing
|
||||
// anything else that's running.
|
||||
let title = info.profile.name == liveState.lastActiveName
|
||||
? "\(info.profile.name) \u{21c6}" : info.profile.name
|
||||
let item = NSMenuItem(title: title, action: #selector(pick(_:)), keyEquivalent: "")
|
||||
item.target = self
|
||||
item.representedObject = info.profile.name
|
||||
item.image = ProfileColor.dotImage(for: info.profile.name, dimmed: !info.running)
|
||||
item.state = info.running ? .on : .off
|
||||
item.toolTip = info.running ? "Running — click to focus" : "Click to switch • Shift-click to open alongside"
|
||||
menu.addItem(item)
|
||||
}
|
||||
|
||||
menu.addItem(.separator())
|
||||
let manageItem = NSMenuItem(
|
||||
title: "Manage Profiles\u{2026}", action: #selector(openManageWindow), keyEquivalent: ",")
|
||||
manageItem.keyEquivalentModifierMask = [.command]
|
||||
manageItem.target = self
|
||||
menu.addItem(manageItem)
|
||||
|
||||
let closeItem = NSMenuItem(title: "Close Profile", action: nil, keyEquivalent: "")
|
||||
let closeMenu = NSMenu()
|
||||
let closeable = profiles.filter(\.running)
|
||||
if closeable.isEmpty {
|
||||
let none = NSMenuItem(title: "(none running)", action: nil, keyEquivalent: "")
|
||||
none.isEnabled = false
|
||||
closeMenu.addItem(none)
|
||||
} else {
|
||||
for info in closeable {
|
||||
let mi = NSMenuItem(title: info.profile.name, action: #selector(closeProfile(_:)), keyEquivalent: "")
|
||||
mi.target = self
|
||||
mi.representedObject = info.profile.name
|
||||
closeMenu.addItem(mi)
|
||||
}
|
||||
}
|
||||
closeItem.submenu = closeMenu
|
||||
menu.addItem(closeItem)
|
||||
|
||||
menu.addItem(.separator())
|
||||
let aboutItem = NSMenuItem(title: "About shannoncoat", action: #selector(showAbout), keyEquivalent: "")
|
||||
aboutItem.target = self
|
||||
menu.addItem(aboutItem)
|
||||
|
||||
menu.addItem(.separator())
|
||||
let quitClaudeItem = NSMenuItem(title: "Quit Claude", action: #selector(quitClaude), keyEquivalent: "")
|
||||
quitClaudeItem.target = self
|
||||
menu.addItem(quitClaudeItem)
|
||||
|
||||
let quitSelfItem = NSMenuItem(title: "Quit shannoncoat", action: #selector(quitSelf), keyEquivalent: "q")
|
||||
quitSelfItem.target = self
|
||||
menu.addItem(quitSelfItem)
|
||||
}
|
||||
|
||||
// Shown once at launch when nothing is running. A checklist rather
|
||||
// than a single pick: unlike the menu's click/Shift-click (one
|
||||
// profile, exclusive vs. alongside), there's nothing running yet to be
|
||||
// "alongside" of, so this lets several profiles be selected to start
|
||||
// together in one go.
|
||||
private func showStartupPicker(_ profiles: [ProfileInfo]) {
|
||||
guard !profiles.isEmpty else { return }
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Start Claude"
|
||||
alert.informativeText = "Nothing is running. Choose which profiles to start:"
|
||||
alert.addButton(withTitle: "Start")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
let rowHeight: CGFloat = 22
|
||||
let container = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: rowHeight * CGFloat(profiles.count)))
|
||||
var checkboxes: [(box: NSButton, name: String)] = []
|
||||
for (i, info) in profiles.enumerated() {
|
||||
let y = rowHeight * CGFloat(profiles.count - 1 - i)
|
||||
let box = NSButton(checkboxWithTitle: info.profile.name, target: nil, action: nil)
|
||||
box.frame = NSRect(x: 0, y: y, width: 240, height: rowHeight)
|
||||
box.state = info.profile.name == liveState.lastActiveName ? .on : .off
|
||||
container.addSubview(box)
|
||||
checkboxes.append((box, info.profile.name))
|
||||
}
|
||||
alert.accessoryView = container
|
||||
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
|
||||
for (box, name) in checkboxes where box.state == .on {
|
||||
guard let profile = profiles.first(where: { $0.profile.name == name })?.profile else { continue }
|
||||
liveState.markActive(name)
|
||||
ClaudeControl.launch(profile)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.liveState.reconcile() }
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc func pick(_ sender: NSMenuItem) {
|
||||
guard let name = sender.representedObject as? String,
|
||||
let profile = currentInfos.first(where: { $0.profile.name == name })?.profile
|
||||
else { return }
|
||||
liveState.markActive(name)
|
||||
if NSApp.currentEvent?.modifierFlags.contains(.shift) == true {
|
||||
openProfile(profile)
|
||||
} else {
|
||||
switchTo(profile)
|
||||
}
|
||||
}
|
||||
|
||||
private func openProfile(_ profile: ResolvedProfile) {
|
||||
if let pid = ClaudeControl.pid(for: profile) {
|
||||
ClaudeControl.focus(pid: pid) { _ in DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } }
|
||||
} else {
|
||||
ClaudeControl.launch(profile)
|
||||
// The launch notification usually beats this, but a short
|
||||
// explicit poke keeps the UI snappy even if it doesn't.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.liveState.reconcile() }
|
||||
}
|
||||
}
|
||||
|
||||
private func switchTo(_ profile: ResolvedProfile) {
|
||||
let others = currentInfos.filter { $0.running && $0.profile.name != profile.name }
|
||||
guard !others.isEmpty else { openProfile(profile); return }
|
||||
var remaining = others.count
|
||||
for info in others {
|
||||
guard let pid = info.pid else { remaining -= 1; continue }
|
||||
ClaudeControl.quit(pid: pid) { [weak self] _ in
|
||||
remaining -= 1
|
||||
if remaining == 0 {
|
||||
DispatchQueue.main.async { self?.openProfile(profile) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func closeProfile(_ sender: NSMenuItem) {
|
||||
guard let name = sender.representedObject as? String,
|
||||
let info = currentInfos.first(where: { $0.profile.name == name }), let pid = info.pid
|
||||
else { return }
|
||||
ClaudeControl.quit(pid: pid) { _ in DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } }
|
||||
}
|
||||
|
||||
@objc func openManageWindow() {
|
||||
ManageWindowController.shared.update(currentInfos)
|
||||
ManageWindowController.shared.show()
|
||||
}
|
||||
|
||||
@objc func showAbout() {
|
||||
// Accessory apps (no Dock icon) don't auto-activate for a window
|
||||
// they open, so the standard About panel would appear behind
|
||||
// whatever's frontmost without this.
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
let credits = """
|
||||
Which coat will Claude wear today? Menu bar switcher for running multiple isolated Claude Desktop profiles side by side — each paired with its own Claude Code config so the two never cross-contaminate.
|
||||
|
||||
Author: \(author)
|
||||
Source: \(homepage)
|
||||
|
||||
Version: \(UpdateChecker.currentVersion)
|
||||
"""
|
||||
NSApp.orderFrontStandardAboutPanel(options: [
|
||||
.applicationName: "shannoncoat",
|
||||
.credits: NSAttributedString(string: credits),
|
||||
])
|
||||
}
|
||||
|
||||
@objc func quitClaude() {
|
||||
ClaudeControl.quitAll { DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } }
|
||||
}
|
||||
|
||||
@objc func quitSelf() {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// Everything about controlling the Claude process itself: launch, focus,
|
||||
// quit. Only two real primitives — launch/focus one profile ("open"), and
|
||||
// quit one profile ("close"); "switch" (exclusive) is composed from these
|
||||
// two in AppDelegate rather than being a third code path here.
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum ClaudeControl {
|
||||
private static let appPathKey = "ClaudeAppPath"
|
||||
|
||||
// Both places an app legitimately lives on macOS: the machine-wide
|
||||
// /Applications and a per-user ~/Applications. Checked in that order,
|
||||
// after any location the user has pointed us at themselves.
|
||||
private static var candidatePaths: [String] {
|
||||
[
|
||||
"/Applications/Claude.app",
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Applications/Claude.app").path,
|
||||
]
|
||||
}
|
||||
|
||||
private static var cachedAppPath: String?
|
||||
|
||||
// Resolved once per launch, because this is consulted on every process
|
||||
// enumeration — which happens on every workspace notification.
|
||||
static var appPath: String {
|
||||
if let cachedAppPath { return cachedAppPath }
|
||||
let resolved = resolveAppPath()
|
||||
cachedAppPath = resolved
|
||||
return resolved
|
||||
}
|
||||
|
||||
static var binaryPath: String { "\(appPath)/Contents/MacOS/Claude" }
|
||||
|
||||
static var isInstalled: Bool { FileManager.default.fileExists(atPath: binaryPath) }
|
||||
|
||||
private static func holdsClaude(_ path: String) -> Bool {
|
||||
FileManager.default.fileExists(atPath: "\(path)/Contents/MacOS/Claude")
|
||||
}
|
||||
|
||||
private static func resolveAppPath() -> String {
|
||||
if let saved = UserDefaults.standard.string(forKey: appPathKey), holdsClaude(saved) {
|
||||
return saved
|
||||
}
|
||||
if let found = candidatePaths.first(where: holdsClaude) {
|
||||
return found
|
||||
}
|
||||
// Found nothing. Report against the standard location anyway, so
|
||||
// anything that surfaces this path names somewhere meaningful
|
||||
// rather than an empty string.
|
||||
return candidatePaths[0]
|
||||
}
|
||||
|
||||
// For an install in neither standard location. Modal by nature, but only
|
||||
// ever reached from an explicit user action that can't proceed without
|
||||
// an answer — better than a menu click that silently does nothing.
|
||||
@discardableResult
|
||||
static func promptForAppLocation() -> Bool {
|
||||
let panel = NSOpenPanel()
|
||||
panel.message = "Couldn't find Claude in Applications. Choose Claude.app to continue."
|
||||
panel.prompt = "Choose"
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.allowedContentTypes = [.application]
|
||||
panel.directoryURL = URL(fileURLWithPath: "/Applications")
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return false }
|
||||
guard holdsClaude(url.path) else {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "That doesn't look like Claude."
|
||||
alert.informativeText = "\(url.lastPathComponent) doesn't contain a Claude executable."
|
||||
alert.runModal()
|
||||
return false
|
||||
}
|
||||
|
||||
UserDefaults.standard.set(url.path, forKey: appPathKey)
|
||||
cachedAppPath = url.path
|
||||
return true
|
||||
}
|
||||
|
||||
static func runningInstances() -> [RunningClaude] {
|
||||
ProcessInspector.listRunningClaude(binaryPath: binaryPath)
|
||||
}
|
||||
|
||||
static func pid(for profile: ResolvedProfile) -> pid_t? {
|
||||
let dir = profile.name == "default" ? nil : profile.appDir
|
||||
return ProcessInspector.pid(forUserDataDir: dir, binaryPath: binaryPath)
|
||||
}
|
||||
|
||||
// MARK: - Launch
|
||||
|
||||
@discardableResult
|
||||
static func launch(_ profile: ResolvedProfile) -> Bool {
|
||||
// Ask once rather than fail silently: a launch that does nothing
|
||||
// gives the user no way to work out that Claude simply isn't where
|
||||
// this expected it to be.
|
||||
if !isInstalled, !promptForAppLocation() { return false }
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: binaryPath)
|
||||
process.standardOutput = FileHandle.nullDevice
|
||||
process.standardError = FileHandle.nullDevice
|
||||
|
||||
if profile.name != "default" {
|
||||
try? FileManager.default.createDirectory(atPath: profile.codeDir, withIntermediateDirectories: true)
|
||||
try? FileManager.default.createDirectory(atPath: profile.appDir, withIntermediateDirectories: true)
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["CLAUDE_CONFIG_DIR"] = profile.codeDir
|
||||
process.environment = env
|
||||
process.arguments = ["--user-data-dir=\(profile.appDir)"]
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Focus
|
||||
|
||||
// Robust focus: unhide, un-minimize, raise across Spaces via the
|
||||
// Accessibility API directly (replacing the old AppleScript/System
|
||||
// Events call), then NSRunningApplication.activate() as a second push.
|
||||
// Waits briefly for a window to exist if the process was just launched,
|
||||
// rather than giving up immediately. Best-effort by design (a focus
|
||||
// request shouldn't be able to crash or block the caller), but no
|
||||
// longer silent about the one failure that's actually actionable:
|
||||
// Accessibility not granted.
|
||||
static func focus(_ profile: ResolvedProfile, completion: @escaping (Bool) -> Void) {
|
||||
guard let pid = pid(for: profile) else { completion(false); return }
|
||||
focus(pid: pid, completion: completion)
|
||||
}
|
||||
|
||||
static func focus(pid: pid_t, completion: @escaping (Bool) -> Void) {
|
||||
if !AXIsProcessTrusted() {
|
||||
promptForAccessibility()
|
||||
}
|
||||
guard let app = NSRunningApplication(processIdentifier: pid) else { completion(false); return }
|
||||
if app.isHidden { app.unhide() }
|
||||
|
||||
let axApp = AXUIElementCreateApplication(pid)
|
||||
waitForWindow(axApp: axApp, attemptsRemaining: 12) { window in
|
||||
if let window {
|
||||
setMinimized(window, false)
|
||||
AXUIElementPerformAction(window, kAXRaiseAction as CFString)
|
||||
}
|
||||
app.activate()
|
||||
completion(window != nil)
|
||||
}
|
||||
}
|
||||
|
||||
private static func waitForWindow(
|
||||
axApp: AXUIElement, attemptsRemaining: Int, completion: @escaping (AXUIElement?) -> Void
|
||||
) {
|
||||
if let window = firstWindow(of: axApp) {
|
||||
completion(window)
|
||||
} else if attemptsRemaining <= 0 {
|
||||
completion(nil)
|
||||
} else {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
waitForWindow(axApp: axApp, attemptsRemaining: attemptsRemaining - 1, completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func firstWindow(of axApp: AXUIElement) -> AXUIElement? {
|
||||
var value: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &value) == .success,
|
||||
let windows = value as? [AXUIElement], let first = windows.first
|
||||
else { return nil }
|
||||
return first
|
||||
}
|
||||
|
||||
private static func setMinimized(_ window: AXUIElement, _ minimized: Bool) {
|
||||
AXUIElementSetAttributeValue(window, kAXMinimizedAttribute as CFString, minimized as CFTypeRef)
|
||||
}
|
||||
|
||||
private static func promptForAccessibility() {
|
||||
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
|
||||
_ = AXIsProcessTrustedWithOptions(options)
|
||||
}
|
||||
|
||||
// MARK: - Quit
|
||||
|
||||
enum QuitResult { case exited, blockedByOwnDialog, forceKilled, alreadyGone }
|
||||
|
||||
// Fixes a real bug in the old shell version: it waited a flat 5s then
|
||||
// unconditionally SIGKILLed, which would kill Claude out from under its
|
||||
// own "unsaved work" / "generation in progress" confirmation if it
|
||||
// showed one. `.terminate()` is a request the target can legitimately
|
||||
// delay or decline (it can return `.terminateCancel` from its own
|
||||
// applicationShouldTerminate:), not a command it's forced to obey — so
|
||||
// this waits much longer, and if Claude still has a window up at the
|
||||
// end of that window (a sign it's showing its own dialog, or otherwise
|
||||
// isn't done with something) it stops short of forceTerminate and
|
||||
// reports that back instead of killing underneath it.
|
||||
static func quit(pid: pid_t, completion: @escaping (QuitResult) -> Void) {
|
||||
guard let app = NSRunningApplication(processIdentifier: pid) else { completion(.alreadyGone); return }
|
||||
app.terminate()
|
||||
pollForExit(app: app, attemptsRemaining: 40, completion: completion) // 40 * 0.25s = 10s grace period
|
||||
}
|
||||
|
||||
static func quitAll(completion: @escaping () -> Void) {
|
||||
let pids = runningInstances().map(\.pid)
|
||||
guard !pids.isEmpty else { completion(); return }
|
||||
var remaining = pids.count
|
||||
for pid in pids {
|
||||
quit(pid: pid) { _ in
|
||||
remaining -= 1
|
||||
if remaining == 0 { completion() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func pollForExit(
|
||||
app: NSRunningApplication, attemptsRemaining: Int, completion: @escaping (QuitResult) -> Void
|
||||
) {
|
||||
if app.isTerminated {
|
||||
completion(.exited)
|
||||
} else if attemptsRemaining <= 0 {
|
||||
if firstWindow(of: AXUIElementCreateApplication(app.processIdentifier)) != nil {
|
||||
completion(.blockedByOwnDialog)
|
||||
} else {
|
||||
app.forceTerminate()
|
||||
completion(.forceKilled)
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
pollForExit(app: app, attemptsRemaining: attemptsRemaining - 1, completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Thin wrapper around the modern login-item API, toggled from the Manage
|
||||
// window's Settings tab. Standard, expected baseline behavior for a
|
||||
// persistent menu-bar utility that's otherwise easy to forget to reopen
|
||||
// after a restart.
|
||||
import ServiceManagement
|
||||
|
||||
enum LaunchAtLogin {
|
||||
static var isEnabled: Bool {
|
||||
SMAppService.mainApp.status == .enabled
|
||||
}
|
||||
|
||||
static func enable() throws {
|
||||
guard SMAppService.mainApp.status != .enabled else { return }
|
||||
try SMAppService.mainApp.register()
|
||||
}
|
||||
|
||||
static func disable() throws {
|
||||
guard SMAppService.mainApp.status == .enabled else { return }
|
||||
try SMAppService.mainApp.unregister()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Keeps the menu bar in sync with reality without ever polling. Registers
|
||||
// NSWorkspace notifications for launch/terminate/activate/hide/unhide,
|
||||
// filtered to Claude's own binary, and reconciles an in-memory
|
||||
// [ProfileInfo] the instant one fires (sub-second, push-based) — this is
|
||||
// what makes external changes (Force Quit, Spotlight, Dock, a hand quit)
|
||||
// show up immediately instead of needing a menu click or an app restart.
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
struct ProfileInfo {
|
||||
let profile: ResolvedProfile
|
||||
let running: Bool
|
||||
let pid: pid_t?
|
||||
}
|
||||
|
||||
protocol LiveStateDelegate: AnyObject {
|
||||
func liveStateDidChange(_ infos: [ProfileInfo])
|
||||
func liveStateFoundCollisions(_ collisions: [ProfileStore.DirCollision])
|
||||
}
|
||||
|
||||
final class LiveState {
|
||||
weak var delegate: LiveStateDelegate?
|
||||
|
||||
// Best-effort "last switched to" pointer for the ⇆ marker in the menu —
|
||||
// reflects profiles opened/switched through this app; there's no
|
||||
// ordered history to fall back through if that one gets hand-quit
|
||||
// (matches the "don't overdo it" brief — reality-vs-stopped state is
|
||||
// still always ground-truth, this only affects the ⇆ hint).
|
||||
private(set) var lastActiveName = "default"
|
||||
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
init() {
|
||||
let center = NSWorkspace.shared.notificationCenter
|
||||
let names: [Notification.Name] = [
|
||||
NSWorkspace.didLaunchApplicationNotification,
|
||||
NSWorkspace.didTerminateApplicationNotification,
|
||||
NSWorkspace.didActivateApplicationNotification,
|
||||
NSWorkspace.didHideApplicationNotification,
|
||||
NSWorkspace.didUnhideApplicationNotification,
|
||||
]
|
||||
observers = names.map { name in
|
||||
center.addObserver(forName: name, object: nil, queue: .main) { [weak self] note in
|
||||
self?.handle(note)
|
||||
}
|
||||
}
|
||||
// Deliberately not calling reconcile() here — `delegate` is set by
|
||||
// the caller right after init, and the caller does the first
|
||||
// reconcile itself once it's ready to receive the callback.
|
||||
}
|
||||
|
||||
deinit {
|
||||
let center = NSWorkspace.shared.notificationCenter
|
||||
observers.forEach { center.removeObserver($0) }
|
||||
}
|
||||
|
||||
private func handle(_ note: Notification) {
|
||||
guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
|
||||
app.executableURL?.path == ClaudeControl.binaryPath
|
||||
else { return }
|
||||
reconcile()
|
||||
}
|
||||
|
||||
func markActive(_ name: String) {
|
||||
lastActiveName = name
|
||||
}
|
||||
|
||||
// Public so callers (menu open, right after a command completes) can
|
||||
// force an immediate reconcile as a correctness backstop, without that
|
||||
// being the primary update mechanism.
|
||||
func reconcile() {
|
||||
let profiles = ProfileStore.loadAll()
|
||||
let collisions = ProfileStore.findCollisions(among: profiles)
|
||||
guard collisions.isEmpty else {
|
||||
delegate?.liveStateFoundCollisions(collisions)
|
||||
return
|
||||
}
|
||||
|
||||
let running = ClaudeControl.runningInstances()
|
||||
let infos = profiles.map { profile -> ProfileInfo in
|
||||
let dir = profile.name == "default" ? nil : profile.appDir
|
||||
let match = running.first { $0.userDataDir == dir }
|
||||
return ProfileInfo(profile: profile, running: match != nil, pid: match?.pid)
|
||||
}
|
||||
delegate?.liveStateDidChange(infos)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
// One ordinary, non-modal window for everything profile-management related
|
||||
// — replaces what would otherwise be a string of separate popup alerts.
|
||||
// Opened from the menu bar's "Manage Profiles…" item or the standard
|
||||
// Cmd+, shortcut.
|
||||
import AppKit
|
||||
|
||||
final class ManageWindowController: NSWindowController {
|
||||
static let shared = ManageWindowController()
|
||||
|
||||
private let profilesVC = ProfilesViewController()
|
||||
private let settingsVC = SettingsViewController()
|
||||
|
||||
// Called after a profile is added/removed so the caller can trigger an
|
||||
// immediate LiveState reconcile instead of waiting on the next
|
||||
// notification.
|
||||
var onProfilesChanged: (() -> Void)? {
|
||||
didSet { profilesVC.onChange = onProfilesChanged }
|
||||
}
|
||||
|
||||
private init() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 460, height: 360),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered, defer: false)
|
||||
window.title = "shannoncoat"
|
||||
window.isReleasedWhenClosed = false
|
||||
window.center()
|
||||
|
||||
let tabs = NSTabViewController()
|
||||
profilesVC.title = "Profiles"
|
||||
settingsVC.title = "Settings"
|
||||
tabs.addTabViewItem(NSTabViewItem(viewController: profilesVC))
|
||||
tabs.addTabViewItem(NSTabViewItem(viewController: settingsVC))
|
||||
window.contentViewController = tabs
|
||||
|
||||
super.init(window: window)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func show() {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
func update(_ infos: [ProfileInfo]) {
|
||||
profilesVC.update(infos)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Profiles tab
|
||||
|
||||
private final class ProfilesViewController: NSViewController, NSTableViewDataSource, NSTableViewDelegate,
|
||||
NSTextFieldDelegate
|
||||
{
|
||||
var onChange: (() -> Void)?
|
||||
|
||||
private var infos: [ProfileInfo] = []
|
||||
private let tableView = NSTableView()
|
||||
private let removeButton = NSButton(title: "\u{2212}", target: nil, action: nil)
|
||||
private let addForm = NSStackView()
|
||||
private let nameField = NSTextField()
|
||||
private let codeField = NSTextField()
|
||||
private let appField = NSTextField()
|
||||
private let errorLabel = NSTextField(labelWithString: "")
|
||||
|
||||
override func loadView() {
|
||||
view = NSView(frame: NSRect(x: 0, y: 0, width: 460, height: 360))
|
||||
|
||||
let column = NSTableColumn(identifier: .init("profile"))
|
||||
column.title = "Profile"
|
||||
column.width = 380
|
||||
tableView.addTableColumn(column)
|
||||
tableView.headerView = nil
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
tableView.rowHeight = 22
|
||||
tableView.menu = buildContextMenu()
|
||||
|
||||
let scroll = NSScrollView()
|
||||
scroll.documentView = tableView
|
||||
scroll.hasVerticalScroller = true
|
||||
scroll.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let addButton = NSButton(title: "+", target: self, action: #selector(toggleAddForm))
|
||||
removeButton.target = self
|
||||
removeButton.action = #selector(removeSelected)
|
||||
removeButton.isEnabled = false
|
||||
let buttonRow = NSStackView(views: [addButton, removeButton, NSView()])
|
||||
buttonRow.orientation = .horizontal
|
||||
buttonRow.spacing = 4
|
||||
buttonRow.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
buildAddForm()
|
||||
addForm.isHidden = true
|
||||
addForm.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let stack = NSStackView(views: [scroll, buttonRow, addForm])
|
||||
stack.orientation = .vertical
|
||||
stack.spacing = 8
|
||||
stack.edgeInsets = NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(stack)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 160),
|
||||
])
|
||||
}
|
||||
|
||||
private func buildAddForm() {
|
||||
nameField.placeholderString = "Profile name"
|
||||
nameField.delegate = self
|
||||
|
||||
codeField.placeholderString = "~/.shannoncoat/data/<name>/code"
|
||||
let codeChoose = NSButton(title: "Choose\u{2026}", target: self, action: #selector(chooseCodeDir))
|
||||
let codeRow = NSStackView(views: [labeled("Claude Code:"), codeField, codeChoose])
|
||||
codeRow.orientation = .horizontal
|
||||
codeRow.spacing = 6
|
||||
|
||||
appField.placeholderString = "~/.shannoncoat/data/<name>/app"
|
||||
let appChoose = NSButton(title: "Choose\u{2026}", target: self, action: #selector(chooseAppDir))
|
||||
let appRow = NSStackView(views: [labeled("Claude Desktop:"), appField, appChoose])
|
||||
appRow.orientation = .horizontal
|
||||
appRow.spacing = 6
|
||||
|
||||
errorLabel.textColor = .systemRed
|
||||
errorLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||
|
||||
let createButton = NSButton(title: "Create", target: self, action: #selector(createProfile))
|
||||
createButton.keyEquivalent = "\r"
|
||||
let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(toggleAddForm))
|
||||
let buttonRow = NSStackView(views: [NSView(), cancelButton, createButton])
|
||||
buttonRow.orientation = .horizontal
|
||||
buttonRow.spacing = 8
|
||||
|
||||
addForm.orientation = .vertical
|
||||
addForm.alignment = .leading
|
||||
addForm.spacing = 6
|
||||
[nameField, codeRow, appRow, errorLabel, buttonRow].forEach(addForm.addArrangedSubview)
|
||||
nameField.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
|
||||
codeRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
|
||||
appRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
|
||||
buttonRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
|
||||
}
|
||||
|
||||
private func labeled(_ text: String) -> NSTextField {
|
||||
let field = NSTextField(labelWithString: text)
|
||||
field.setContentHuggingPriority(.required, for: .horizontal)
|
||||
return field
|
||||
}
|
||||
|
||||
func update(_ infos: [ProfileInfo]) {
|
||||
self.infos = infos
|
||||
tableView.reloadData()
|
||||
removeButton.isEnabled = tableView.selectedRow >= 0
|
||||
&& infos.indices.contains(tableView.selectedRow)
|
||||
&& infos[tableView.selectedRow].profile.name != "default"
|
||||
}
|
||||
|
||||
// MARK: NSTableViewDataSource / Delegate
|
||||
|
||||
func numberOfRows(in tableView: NSTableView) -> Int { infos.count }
|
||||
|
||||
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||||
let identifier = NSUserInterfaceItemIdentifier("profileCell")
|
||||
let info = infos[row]
|
||||
let cell: NSTableCellView
|
||||
if let reused = tableView.makeView(withIdentifier: identifier, owner: self) as? NSTableCellView {
|
||||
cell = reused
|
||||
} else {
|
||||
cell = NSTableCellView()
|
||||
cell.identifier = identifier
|
||||
let imageView = NSImageView()
|
||||
let textField = NSTextField(labelWithString: "")
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
textField.translatesAutoresizingMaskIntoConstraints = false
|
||||
cell.addSubview(imageView)
|
||||
cell.addSubview(textField)
|
||||
cell.imageView = imageView
|
||||
cell.textField = textField
|
||||
NSLayoutConstraint.activate([
|
||||
imageView.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4),
|
||||
imageView.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
||||
imageView.widthAnchor.constraint(equalToConstant: 10),
|
||||
imageView.heightAnchor.constraint(equalToConstant: 10),
|
||||
textField.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 6),
|
||||
textField.trailingAnchor.constraint(lessThanOrEqualTo: cell.trailingAnchor, constant: -4),
|
||||
textField.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
||||
])
|
||||
}
|
||||
cell.imageView?.image = ProfileColor.dotImage(for: info.profile.name, dimmed: !info.running)
|
||||
cell.textField?.stringValue = "\(info.profile.name) — \(info.running ? "running" : "stopped")"
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableViewSelectionDidChange(_ notification: Notification) {
|
||||
removeButton.isEnabled = tableView.selectedRow >= 0
|
||||
&& infos.indices.contains(tableView.selectedRow)
|
||||
&& infos[tableView.selectedRow].profile.name != "default"
|
||||
}
|
||||
|
||||
// MARK: NSTextFieldDelegate — live default-path preview as the name is typed
|
||||
|
||||
func controlTextDidChange(_ obj: Notification) {
|
||||
guard (obj.object as? NSTextField) === nameField else { return }
|
||||
let name = nameField.stringValue.isEmpty ? "<name>" : nameField.stringValue
|
||||
codeField.placeholderString = "~/.shannoncoat/data/\(name)/code"
|
||||
appField.placeholderString = "~/.shannoncoat/data/\(name)/app"
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
@objc private func toggleAddForm() {
|
||||
addForm.isHidden.toggle()
|
||||
if !addForm.isHidden {
|
||||
nameField.stringValue = ""
|
||||
codeField.stringValue = ""
|
||||
appField.stringValue = ""
|
||||
errorLabel.stringValue = ""
|
||||
view.window?.makeFirstResponder(nameField)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func chooseCodeDir() { choose(into: codeField) }
|
||||
@objc private func chooseAppDir() { choose(into: appField) }
|
||||
|
||||
private func choose(into field: NSTextField) {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
panel.allowsMultipleSelection = false
|
||||
guard let window = view.window else { return }
|
||||
panel.beginSheetModal(for: window) { response in
|
||||
if response == .OK, let url = panel.url {
|
||||
field.stringValue = url.path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func createProfile() {
|
||||
let name = nameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let code = codeField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let app = appField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
do {
|
||||
try ProfileStore.create(name: name, codeDir: code.isEmpty ? nil : code, appDir: app.isEmpty ? nil : app)
|
||||
toggleAddForm()
|
||||
onChange?()
|
||||
} catch {
|
||||
errorLabel.stringValue = (error as? LocalizedError)?.errorDescription ?? "\(error)"
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func removeSelected() {
|
||||
let row = tableView.selectedRow
|
||||
guard infos.indices.contains(row) else { return }
|
||||
let info = infos[row]
|
||||
guard info.profile.name != "default", let window = view.window else { return }
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "Delete profile \u{201C}\(info.profile.name)\u{201D}?"
|
||||
alert.informativeText = info.running
|
||||
? "This profile is currently running \u{2014} deleting it will quit Claude first. Its Claude Desktop and Claude Code data stay on disk; only the profile pointer is removed."
|
||||
: "Its Claude Desktop and Claude Code data stay on disk; only the profile pointer is removed."
|
||||
alert.addButton(withTitle: "Delete")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
alert.buttons.first?.hasDestructiveAction = true
|
||||
|
||||
alert.beginSheetModal(for: window) { [weak self] response in
|
||||
guard response == .alertFirstButtonReturn else { return }
|
||||
let finish = {
|
||||
try? ProfileStore.delete(info.profile.name)
|
||||
self?.onChange?()
|
||||
}
|
||||
if info.running, let pid = info.pid {
|
||||
ClaudeControl.quit(pid: pid) { _ in DispatchQueue.main.async(execute: finish) }
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func buildContextMenu() -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
let revealCode = NSMenuItem(title: "Reveal Code Folder", action: #selector(revealCodeDir), keyEquivalent: "")
|
||||
revealCode.target = self
|
||||
let revealApp = NSMenuItem(
|
||||
title: "Reveal Desktop Folder", action: #selector(revealAppDir), keyEquivalent: "")
|
||||
revealApp.target = self
|
||||
menu.addItem(revealCode)
|
||||
menu.addItem(revealApp)
|
||||
return menu
|
||||
}
|
||||
|
||||
private func clickedInfo() -> ProfileInfo? {
|
||||
let row = tableView.clickedRow
|
||||
guard infos.indices.contains(row) else { return nil }
|
||||
return infos[row]
|
||||
}
|
||||
|
||||
@objc private func revealCodeDir() {
|
||||
guard let info = clickedInfo() else { return }
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: info.profile.codeDir)
|
||||
}
|
||||
|
||||
@objc private func revealAppDir() {
|
||||
guard let info = clickedInfo() else { return }
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: info.profile.appDir)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings tab
|
||||
|
||||
private final class SettingsViewController: NSViewController {
|
||||
private let autoUpdateCheckbox = NSButton(
|
||||
checkboxWithTitle: "Automatically check for updates", target: nil, action: nil)
|
||||
private let launchAtLoginCheckbox = NSButton(checkboxWithTitle: "Launch at Login", target: nil, action: nil)
|
||||
private let checkNowButton = NSButton(title: "Check for Updates\u{2026}", target: nil, action: nil)
|
||||
|
||||
override func loadView() {
|
||||
view = NSView(frame: NSRect(x: 0, y: 0, width: 460, height: 360))
|
||||
|
||||
launchAtLoginCheckbox.target = self
|
||||
launchAtLoginCheckbox.action = #selector(toggleLaunchAtLogin)
|
||||
launchAtLoginCheckbox.state = LaunchAtLogin.isEnabled ? .on : .off
|
||||
|
||||
autoUpdateCheckbox.target = self
|
||||
autoUpdateCheckbox.action = #selector(toggleAutoUpdate)
|
||||
autoUpdateCheckbox.state = UpdateChecker.automaticCheckEnabled ? .on : .off
|
||||
|
||||
checkNowButton.target = self
|
||||
checkNowButton.action = #selector(checkNow)
|
||||
|
||||
let stack = NSStackView(views: [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton])
|
||||
stack.orientation = .vertical
|
||||
stack.alignment = .leading
|
||||
stack.spacing = 12
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
|
||||
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||
])
|
||||
}
|
||||
|
||||
@objc private func toggleLaunchAtLogin() {
|
||||
do {
|
||||
if launchAtLoginCheckbox.state == .on {
|
||||
try LaunchAtLogin.enable()
|
||||
} else {
|
||||
try LaunchAtLogin.disable()
|
||||
}
|
||||
} catch {
|
||||
launchAtLoginCheckbox.state = LaunchAtLogin.isEnabled ? .on : .off
|
||||
NSAlert(error: error).runModal()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func toggleAutoUpdate() {
|
||||
UpdateChecker.automaticCheckEnabled = autoUpdateCheckbox.state == .on
|
||||
}
|
||||
|
||||
@objc private func checkNow() {
|
||||
UpdateChecker.checkManually { result in
|
||||
DispatchQueue.main.async {
|
||||
let alert = NSAlert()
|
||||
switch result {
|
||||
case .upToDate:
|
||||
alert.messageText = "You're up to date."
|
||||
alert.runModal()
|
||||
case .updateAvailable(let version, let url):
|
||||
alert.messageText = "shannoncoat \(version) is available"
|
||||
alert.addButton(withTitle: "View Release")
|
||||
alert.addButton(withTitle: "Later")
|
||||
if alert.runModal() == .alertFirstButtonReturn {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
case .failed:
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "Couldn't check for updates."
|
||||
alert.runModal()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// A stable, well-distributed color per profile name, shared by the menu
|
||||
// bar dots and the Manage window's profile list so the same name always
|
||||
// reads as the same color everywhere.
|
||||
import AppKit
|
||||
|
||||
enum ProfileColor {
|
||||
// FNV-1a: a proper avalanching hash, not a preset-palette lookup — the
|
||||
// full hue wheel is available, and single-character name edits (e.g.
|
||||
// "personal" vs "personal2") land on unrelated hues instead of an
|
||||
// adjacent bucket the way a plain sum-of-scalars hash mod'd into a
|
||||
// short color list would.
|
||||
private static func fnv1aHash(_ s: String) -> UInt32 {
|
||||
var hash: UInt32 = 0x811c_9dc5
|
||||
for byte in s.utf8 {
|
||||
hash ^= UInt32(byte)
|
||||
hash = hash &* 0x0100_0193
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
static func dotColor(for name: String) -> NSColor {
|
||||
let hue = CGFloat(fnv1aHash(name) % 360) / 360.0
|
||||
return NSColor(calibratedHue: hue, saturation: 0.65, brightness: 0.9, alpha: 1.0)
|
||||
}
|
||||
|
||||
// `dimmed` marks a profile that isn't actually running right now — the
|
||||
// colored dot still identifies it, but faded, so "open" vs "known but
|
||||
// closed" is visible without reading a tooltip.
|
||||
static func dotImage(for name: String, dimmed: Bool = false) -> NSImage {
|
||||
let size = NSSize(width: 10, height: 10)
|
||||
let image = NSImage(size: size)
|
||||
image.lockFocus()
|
||||
dotColor(for: name).withAlphaComponent(dimmed ? 0.3 : 1.0).setFill()
|
||||
NSBezierPath(ovalIn: NSRect(origin: .zero, size: size)).fill()
|
||||
image.unlockFocus()
|
||||
image.isTemplate = false
|
||||
return image
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Deliberately minimal: fetch the raw VERSION file from `main` on GitHub
|
||||
// and compare semver to the bundled version. No silent binary-replacing
|
||||
// auto-update — that needs signing/notarization/an appcast server this
|
||||
// project doesn't have. A manual check shows an alert (an explicit action
|
||||
// that expects a response); the periodic automatic check instead posts a
|
||||
// quiet system notification, since that one wasn't asked for in the
|
||||
// moment.
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
enum UpdateChecker {
|
||||
enum Outcome {
|
||||
case upToDate
|
||||
case updateAvailable(version: String, url: URL)
|
||||
case failed
|
||||
}
|
||||
|
||||
private static let versionURL = URL(string: "https://raw.githubusercontent.com/bdeshi/shannoncoat/main/VERSION")!
|
||||
private static let releaseURL = URL(string: "https://github.com/bdeshi/shannoncoat/releases/latest")!
|
||||
private static let enabledDefaultsKey = "AutomaticUpdateCheckEnabled"
|
||||
private static let lastCheckDefaultsKey = "LastUpdateCheckDate"
|
||||
private static let checkInterval: TimeInterval = 24 * 60 * 60
|
||||
|
||||
static var automaticCheckEnabled: Bool {
|
||||
get { UserDefaults.standard.object(forKey: enabledDefaultsKey) as? Bool ?? true }
|
||||
set { UserDefaults.standard.set(newValue, forKey: enabledDefaultsKey) }
|
||||
}
|
||||
|
||||
static var currentVersion: String {
|
||||
guard let resourceURL = Bundle.main.resourceURL,
|
||||
let contents = try? String(contentsOf: resourceURL.appendingPathComponent("VERSION"), encoding: .utf8)
|
||||
else { return "unknown" }
|
||||
return contents.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
static func checkManually(completion: @escaping (Outcome) -> Void) {
|
||||
fetchLatestVersion { latest in
|
||||
guard let latest else { completion(.failed); return }
|
||||
if isNewer(latest, than: currentVersion) {
|
||||
completion(.updateAvailable(version: latest, url: releaseURL))
|
||||
} else {
|
||||
completion(.upToDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called once at launch; a no-op unless the toggle is on and it's been
|
||||
// at least a day since the last check.
|
||||
static func checkAutomaticallyIfDue() {
|
||||
guard automaticCheckEnabled else { return }
|
||||
let last = UserDefaults.standard.object(forKey: lastCheckDefaultsKey) as? Date ?? .distantPast
|
||||
guard Date().timeIntervalSince(last) > checkInterval else { return }
|
||||
UserDefaults.standard.set(Date(), forKey: lastCheckDefaultsKey)
|
||||
|
||||
fetchLatestVersion { latest in
|
||||
guard let latest, isNewer(latest, than: currentVersion) else { return }
|
||||
postAvailableNotification(version: latest)
|
||||
}
|
||||
}
|
||||
|
||||
private static func fetchLatestVersion(completion: @escaping (String?) -> Void) {
|
||||
URLSession.shared.dataTask(with: versionURL) { data, _, _ in
|
||||
let version = data.flatMap { String(data: $0, encoding: .utf8) }?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
completion((version?.isEmpty == false) ? version : nil)
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private static func isNewer(_ a: String, than b: String) -> Bool {
|
||||
let av = a.split(separator: ".").compactMap { Int($0) }
|
||||
let bv = b.split(separator: ".").compactMap { Int($0) }
|
||||
for i in 0..<max(av.count, bv.count) {
|
||||
let x = i < av.count ? av[i] : 0
|
||||
let y = i < bv.count ? bv[i] : 0
|
||||
if x != y { return x > y }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func postAvailableNotification(version: String) {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.requestAuthorization(options: [.alert]) { granted, _ in
|
||||
guard granted else { return }
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "shannoncoat \(version) is available"
|
||||
content.body = "Click to view the release notes."
|
||||
center.add(UNNotificationRequest(identifier: "shannoncoat-update-available", content: content, trigger: nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Entry point. Its own file only because swiftc restricts top-level
|
||||
// executable statements to a file named main.swift once a module has more
|
||||
// than one source file.
|
||||
import Cocoa
|
||||
|
||||
let delegate = AppDelegate()
|
||||
let app = NSApplication.shared
|
||||
app.delegate = delegate
|
||||
app.setActivationPolicy(.accessory)
|
||||
app.run()
|
||||
Reference in New Issue
Block a user