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.
391 lines
16 KiB
Swift
391 lines
16 KiB
Swift
// 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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|