Four separate defects, each reproduced before being fixed. Accessibility was silently denied. AXIsProcessTrusted() returned false, so every AX read failed, WindowOverlay.init? failed all twelve retries, and attach gave up without a word — indistinguishable from the feature being broken. The cause was outside this file: an ad-hoc-signed app has its permission pinned to its exact cdhash, which changes on every build, so the rebuild-and-replace workflow silently revoked the grant each time while the app stayed listed and ticked in System Settings. Now it prompts rather than failing mutely, and keeps rechecking — macOS never tells an app it has just been granted Accessibility, and overlays were otherwise only reconsidered when a Claude window launched, quit or activated, so a grant made while running did nothing visible until the user happened to touch a Claude window. The tag walked off its window on every drag. windowDidMove re-derived and persisted the corner offset for the app's own programmatic moves too, on the assumption that recomputing an offset it had just positioned against was a no-op. It isn't: AppKit posts that notification synchronously from inside setFrame, and during a live drag the AX frame read there is already newer than the one reposition used, so each event banked the few points the window had moved in between. Observed saturating at 952, which parks the tag at the far edge of the window or off-screen. Placement was anchored to the top-right unconditionally, so any resize moving that corner dragged the tag along. It now anchors to whichever corner it was dropped nearest. Offsets are no longer written back clamped either: a window too small to honour one shows the tag pushed in as far as it fits but keeps the stored distance, so widening the window restores the chosen spot. Styling: thinner (13pt) and pill-shaped so it can sit close to an edge without covering window controls; rotated when against a side edge, reading bottom-to-top on the left and top-to-bottom on the right so the text leans into the window; text colour chosen per profile colour by WCAG relative luminance rather than fixed white, which was as low as ~1.1:1 on a yellow chip against 4.50:1 worst-case now; and text drawn into a one-line-tall rect centred on the chip's midline, fixing its high seating. Settings gains a choice between the name chip and a plain coloured dot, which is never rotated, having no reading direction. Verified live throughout: a 400pt offset squeezed to 260 on a narrowed window and returned to 400 on restore with the stored value untouched; a drag committed the expected corner and orientation; and contrast was measured across the hue wheel. Tag placement is also stored per profile rather than once for the whole app. Two windows side by side are the case these tags exist for, and wanting each one's tag somewhere different is the normal outcome — one window's sidebar is not another's. Previously a single shared offset made them look independent (only the dragged tag moved at once) while every other tag snapped to it on its next reposition. A tag is a floating panel, which puts it above every ordinary window on the system rather than merely above the window it labels — so it hovered over the browser, the editor, and everything else, even when its own window was buried or on another Space. There is no cross-process way to attach one window above another (addChildWindow is same-process only, and the private ordering call window managers use is a one-shot that goes stale on the next reorder), so each tag now checks whether the window it labels is genuinely visible beneath it. The window server returns its list front-to-back, so one pass answers it: anything overlapping the tag before we reach our own window is covering it, and never reaching that window means it isn't on screen at all. Tag position is also clamped into whatever part of the window is on screen. A window dragged half off the edge takes its anchored corner with it, and a tag that follows it out of view identifies nothing. The clamp is presentational only — the stored anchor is untouched, so the tag returns to it once the window is fully back. Occlusion is judged from the window server's listing, which it returns front-to-back. Our own window is identified by its owning process rather than by matching rectangles: AX reports a new position the instant a window moves while the listing still holds the previous one, so a geometric match fails almost continuously mid-drag — measured at 87 of 92 frames, during which the walk ran past our own window and mistook whatever else overlapped the tag for something covering it. Only ordinary windows count as occluders. Everything above that band is permanently in front and would veto the tag forever — including the invisible one-pixel markers some utilities park in a screen corner, which is what made a tag vanish at the bottom-left and nowhere else: it takes a window moved off two edges at once for the tag to clamp into that pixel.
703 lines
31 KiB
Swift
703 lines
31 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. Fixed-size (not user-resizable) — each tab reports its
|
|
// own natural size via preferredContentSize, and NSTabViewController
|
|
// resizes the window to match automatically, so it's never left showing
|
|
// empty space sized for a different tab or state.
|
|
import AppKit
|
|
|
|
final class ManageWindowController: NSWindowController {
|
|
static let shared = ManageWindowController()
|
|
|
|
enum Tab: Int { case profiles = 0, settings = 1, about = 2 }
|
|
|
|
private let profilesVC = ProfilesViewController()
|
|
private let settingsVC = SettingsViewController()
|
|
private let aboutVC = AboutViewController()
|
|
private let tabs = NSTabViewController()
|
|
|
|
// 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 }
|
|
}
|
|
|
|
// Called when the window-tag style changes, so open tags can be
|
|
// rebuilt in the new style immediately rather than at the next
|
|
// launch/quit of a Claude window.
|
|
var onOverlayStyleChanged: (() -> Void)? {
|
|
didSet { settingsVC.onOverlayStyleChanged = onOverlayStyleChanged }
|
|
}
|
|
|
|
private init() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
|
styleMask: [.titled, .closable, .miniaturizable],
|
|
backing: .buffered, defer: false)
|
|
window.title = "shannoncoat"
|
|
window.isReleasedWhenClosed = false
|
|
window.center()
|
|
|
|
profilesVC.title = "Profiles"
|
|
settingsVC.title = "Settings"
|
|
aboutVC.title = "About"
|
|
tabs.addTabViewItem(NSTabViewItem(viewController: profilesVC))
|
|
tabs.addTabViewItem(NSTabViewItem(viewController: settingsVC))
|
|
tabs.addTabViewItem(NSTabViewItem(viewController: aboutVC))
|
|
window.contentViewController = tabs
|
|
|
|
super.init(window: window)
|
|
}
|
|
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
func show(tab: Tab? = nil) {
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
if let tab {
|
|
tabs.selectedTabViewItemIndex = tab.rawValue
|
|
}
|
|
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 addRemoveControl = NSSegmentedControl()
|
|
private let detailStack = NSStackView()
|
|
private let detailAppValue = NSTextField(labelWithString: "")
|
|
private let detailCodeValue = NSTextField(labelWithString: "")
|
|
private let detailAppCopyItem = NSMenuItem(title: "Copy Path", action: #selector(copyDetailPath(_:)), keyEquivalent: "")
|
|
private let detailCodeCopyItem = NSMenuItem(title: "Copy Path", action: #selector(copyDetailPath(_:)), keyEquivalent: "")
|
|
private let addForm = NSStackView()
|
|
private let nameField = NSTextField()
|
|
private let codeField = NSTextField()
|
|
private let appField = NSTextField()
|
|
private let errorLabel = NSTextField(labelWithString: "")
|
|
private var outerStack: NSStackView!
|
|
private var scrollHeightConstraint: NSLayoutConstraint!
|
|
|
|
private static let margin: CGFloat = 20
|
|
private static let tableWidth: CGFloat = 340
|
|
private static let minVisibleRows = 3
|
|
private static let maxVisibleRows = 8
|
|
|
|
override func loadView() {
|
|
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 260))
|
|
|
|
let column = NSTableColumn(identifier: .init("profile"))
|
|
column.title = "Profile"
|
|
column.width = 340
|
|
tableView.addTableColumn(column)
|
|
tableView.headerView = nil
|
|
tableView.dataSource = self
|
|
tableView.delegate = self
|
|
tableView.rowHeight = 24
|
|
tableView.usesAlternatingRowBackgroundColors = true
|
|
tableView.menu = buildContextMenu()
|
|
|
|
let scroll = NSScrollView()
|
|
scroll.documentView = tableView
|
|
scroll.hasVerticalScroller = true
|
|
scroll.borderType = .bezelBorder
|
|
scroll.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
addRemoveControl.segmentCount = 2
|
|
addRemoveControl.trackingMode = .momentary
|
|
addRemoveControl.segmentStyle = .smallSquare
|
|
addRemoveControl.setImage(NSImage(systemSymbolName: "plus", accessibilityDescription: "Add Profile"), forSegment: 0)
|
|
addRemoveControl.setImage(
|
|
NSImage(systemSymbolName: "minus", accessibilityDescription: "Remove Profile"), forSegment: 1)
|
|
addRemoveControl.setWidth(28, forSegment: 0)
|
|
addRemoveControl.setWidth(28, forSegment: 1)
|
|
addRemoveControl.setEnabled(false, forSegment: 1)
|
|
addRemoveControl.setToolTip("Add Profile", forSegment: 0)
|
|
addRemoveControl.setToolTip("Remove Profile", forSegment: 1)
|
|
addRemoveControl.target = self
|
|
addRemoveControl.action = #selector(addRemoveClicked(_:))
|
|
let buttonRow = NSStackView(views: [addRemoveControl, NSView()])
|
|
buttonRow.orientation = .horizontal
|
|
buttonRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
buildDetailStack()
|
|
detailStack.isHidden = true
|
|
detailStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
buildAddForm()
|
|
addForm.isHidden = true
|
|
addForm.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let stack = NSStackView(views: [scroll, buttonRow, detailStack, addForm])
|
|
stack.orientation = .vertical
|
|
stack.spacing = 12
|
|
stack.edgeInsets = NSEdgeInsets(top: Self.margin, left: Self.margin, bottom: Self.margin, right: Self.margin)
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(stack)
|
|
outerStack = stack
|
|
scrollHeightConstraint = scroll.heightAnchor.constraint(equalToConstant: heightForRows(0))
|
|
|
|
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.widthAnchor.constraint(equalToConstant: Self.tableWidth),
|
|
scrollHeightConstraint,
|
|
// `scroll` is the one arranged subview with a real, absolute
|
|
// width — everything else in this stack ties its width to
|
|
// *that*, not to `stack` itself (whose own frame spans
|
|
// edge-to-edge; edgeInsets only govern how the stack lays out
|
|
// its children, not its own bounds). Left unconstrained, these
|
|
// rows have no hard width anywhere in their constraint chain —
|
|
// NSStackView doesn't fill them to the inset content width by
|
|
// default, so they end up sized/positioned ambiguously (in
|
|
// practice: flush against the window edge, sometimes wider
|
|
// than the window).
|
|
buttonRow.widthAnchor.constraint(equalTo: scroll.widthAnchor),
|
|
detailStack.widthAnchor.constraint(equalTo: scroll.widthAnchor),
|
|
addForm.widthAnchor.constraint(equalTo: scroll.widthAnchor),
|
|
])
|
|
|
|
updatePreferredSize()
|
|
}
|
|
|
|
// Sized to the actual row count (clamped to a sensible range) rather
|
|
// than one fixed height — with only a couple of profiles that avoids a
|
|
// big block of empty striped background below the last row; with many
|
|
// it caps out and scrolls instead of taking over the window.
|
|
private func heightForRows(_ count: Int) -> CGFloat {
|
|
let rows = max(Self.minVisibleRows, min(count, Self.maxVisibleRows))
|
|
return CGFloat(rows) * tableView.rowHeight + 2
|
|
}
|
|
|
|
// NSTabViewController watches its selected child's preferredContentSize
|
|
// and resizes the window to match live — so recomputing this after
|
|
// toggling the add form or the path-details row is what makes the
|
|
// window grow/shrink to fit instead of sitting at one fixed size with
|
|
// empty space in the states that don't need it.
|
|
private func updatePreferredSize() {
|
|
view.layoutSubtreeIfNeeded()
|
|
preferredContentSize = outerStack.fittingSize
|
|
}
|
|
|
|
// A second, authoritative recompute after every real layout pass —
|
|
// explicit calls from action handlers (below) update it eagerly for
|
|
// responsiveness, but this is the one that can't be thrown off by
|
|
// metrics not being fully resolved yet at the point an eager call runs.
|
|
override func viewDidLayout() {
|
|
super.viewDidLayout()
|
|
preferredContentSize = outerStack.fittingSize
|
|
}
|
|
|
|
// Shows the selected profile's two dirs — the thing you'd otherwise
|
|
// have to right-click "Reveal" to find out. Not `.isSelectable` — that
|
|
// makes a label click-into-edit, and the field editor that appears
|
|
// draws the *untruncated* string at its full intrinsic width, which
|
|
// can extend past the window and get clipped by it rather than
|
|
// wrapping or scrolling cleanly. A tooltip (full path) plus a
|
|
// right-click "Copy Path" menu covers the same "get the value out"
|
|
// need without that.
|
|
private func buildDetailStack() {
|
|
detailAppCopyItem.target = self
|
|
detailCodeCopyItem.target = self
|
|
let appMenu = NSMenu()
|
|
appMenu.addItem(detailAppCopyItem)
|
|
let codeMenu = NSMenu()
|
|
codeMenu.addItem(detailCodeCopyItem)
|
|
|
|
for (value, menu) in [(detailAppValue, appMenu), (detailCodeValue, codeMenu)] {
|
|
value.textColor = .secondaryLabelColor
|
|
value.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
|
value.lineBreakMode = .byTruncatingMiddle
|
|
value.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
value.menu = menu
|
|
}
|
|
|
|
let appRow = NSStackView(views: [labeled("Claude Desktop:"), detailAppValue])
|
|
appRow.orientation = .horizontal
|
|
appRow.spacing = 8
|
|
let codeRow = NSStackView(views: [labeled("Claude Code:"), detailCodeValue])
|
|
codeRow.orientation = .horizontal
|
|
codeRow.spacing = 8
|
|
|
|
detailStack.orientation = .vertical
|
|
detailStack.alignment = .leading
|
|
detailStack.spacing = 4
|
|
[appRow, codeRow].forEach(detailStack.addArrangedSubview)
|
|
appRow.widthAnchor.constraint(equalTo: detailStack.widthAnchor).isActive = true
|
|
codeRow.widthAnchor.constraint(equalTo: detailStack.widthAnchor).isActive = true
|
|
}
|
|
|
|
private func buildAddForm() {
|
|
nameField.placeholderString = "Profile name"
|
|
nameField.delegate = self
|
|
|
|
for field in [codeField, appField] {
|
|
field.lineBreakMode = .byTruncatingMiddle
|
|
field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
}
|
|
|
|
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 = 8
|
|
|
|
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 = 8
|
|
|
|
errorLabel.textColor = .systemRed
|
|
errorLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
|
errorLabel.maximumNumberOfLines = 0
|
|
errorLabel.lineBreakMode = .byWordWrapping
|
|
|
|
let createButton = NSButton(title: "Create", target: self, action: #selector(createProfile))
|
|
createButton.keyEquivalent = "\r"
|
|
let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(toggleAddForm))
|
|
let formButtonRow = NSStackView(views: [NSView(), cancelButton, createButton])
|
|
formButtonRow.orientation = .horizontal
|
|
formButtonRow.spacing = 8
|
|
|
|
let divider = NSBox()
|
|
divider.boxType = .separator
|
|
|
|
addForm.orientation = .vertical
|
|
addForm.alignment = .leading
|
|
addForm.spacing = 10
|
|
[divider, nameField, codeRow, appRow, errorLabel, formButtonRow].forEach(addForm.addArrangedSubview)
|
|
divider.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
|
|
nameField.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
|
|
codeRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
|
|
appRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
|
|
formButtonRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
|
|
}
|
|
|
|
// Fixed-width, trailing-aligned label so the fields in a two-row form
|
|
// (differing label lengths: "Claude Code:" vs "Claude Desktop:") start
|
|
// at the same x position instead of a ragged left edge.
|
|
private func labeled(_ text: String) -> NSTextField {
|
|
let field = NSTextField(labelWithString: text)
|
|
field.font = .systemFont(ofSize: NSFont.systemFontSize)
|
|
field.textColor = .secondaryLabelColor
|
|
field.setContentHuggingPriority(.required, for: .horizontal)
|
|
field.alignment = .right
|
|
field.widthAnchor.constraint(equalToConstant: 104).isActive = true
|
|
return field
|
|
}
|
|
|
|
func update(_ infos: [ProfileInfo]) {
|
|
self.infos = infos
|
|
tableView.reloadData()
|
|
scrollHeightConstraint.constant = heightForRows(infos.count)
|
|
refreshForSelection()
|
|
}
|
|
|
|
private func refreshForSelection() {
|
|
let row = tableView.selectedRow
|
|
guard infos.indices.contains(row) else {
|
|
addRemoveControl.setEnabled(false, forSegment: 1)
|
|
detailStack.isHidden = true
|
|
updatePreferredSize()
|
|
return
|
|
}
|
|
let profile = infos[row].profile
|
|
addRemoveControl.setEnabled(profile.name != "default", forSegment: 1)
|
|
detailAppValue.stringValue = profile.appDir
|
|
detailAppValue.toolTip = profile.appDir
|
|
detailAppCopyItem.representedObject = profile.appDir
|
|
detailCodeValue.stringValue = profile.codeDir
|
|
detailCodeValue.toolTip = profile.codeDir
|
|
detailCodeCopyItem.representedObject = profile.codeDir
|
|
detailStack.isHidden = false
|
|
updatePreferredSize()
|
|
}
|
|
|
|
@objc private func copyDetailPath(_ sender: NSMenuItem) {
|
|
guard let path = sender.representedObject as? String else { return }
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(path, forType: .string)
|
|
}
|
|
|
|
// 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: 6),
|
|
imageView.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
|
imageView.widthAnchor.constraint(equalToConstant: 10),
|
|
imageView.heightAnchor.constraint(equalToConstant: 10),
|
|
textField.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 8),
|
|
textField.trailingAnchor.constraint(lessThanOrEqualTo: cell.trailingAnchor, constant: -6),
|
|
textField.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
|
])
|
|
}
|
|
cell.imageView?.image = ProfileColor.dotImage(for: info.profile.name, dimmed: !info.running)
|
|
|
|
let text = NSMutableAttributedString(
|
|
string: info.profile.name, attributes: [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)])
|
|
text.append(NSAttributedString(
|
|
string: " \(info.running ? "Running" : "Stopped")",
|
|
attributes: [
|
|
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
|
|
.foregroundColor: NSColor.secondaryLabelColor,
|
|
]))
|
|
cell.textField?.attributedStringValue = text
|
|
return cell
|
|
}
|
|
|
|
func tableViewSelectionDidChange(_ notification: Notification) {
|
|
refreshForSelection()
|
|
}
|
|
|
|
// 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 addRemoveClicked(_ sender: NSSegmentedControl) {
|
|
switch sender.selectedSegment {
|
|
case 0: toggleAddForm()
|
|
case 1: removeSelected()
|
|
default: break
|
|
}
|
|
}
|
|
|
|
@objc private func toggleAddForm() {
|
|
addForm.isHidden.toggle()
|
|
if !addForm.isHidden {
|
|
nameField.stringValue = ""
|
|
codeField.stringValue = ""
|
|
appField.stringValue = ""
|
|
errorLabel.stringValue = ""
|
|
view.window?.makeFirstResponder(nameField)
|
|
}
|
|
updatePreferredSize()
|
|
}
|
|
|
|
@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)"
|
|
updatePreferredSize()
|
|
}
|
|
}
|
|
|
|
@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)
|
|
private let tagStyleLabel = NSTextField(labelWithString: "Window tag:")
|
|
private let tagStylePopUp = NSPopUpButton(frame: .zero, pullsDown: false)
|
|
private var tagStyleRow: NSStackView!
|
|
private var stack: NSStackView!
|
|
|
|
var onOverlayStyleChanged: (() -> Void)?
|
|
|
|
// Order matches the popup's items, so the selected index maps straight
|
|
// onto a style without a title-string comparison.
|
|
private static let tagStyles: [(title: String, style: WindowOverlayStyle)] = [
|
|
("Name chip", .chip),
|
|
("Colored dot", .dot),
|
|
]
|
|
|
|
override func loadView() {
|
|
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 150))
|
|
|
|
tagStylePopUp.addItems(withTitles: Self.tagStyles.map(\.title))
|
|
tagStylePopUp.selectItem(at: Self.tagStyles.firstIndex { $0.style == WindowOverlayPosition.style } ?? 0)
|
|
tagStylePopUp.target = self
|
|
tagStylePopUp.action = #selector(changeTagStyle)
|
|
|
|
tagStyleRow = NSStackView(views: [tagStyleLabel, tagStylePopUp])
|
|
tagStyleRow.orientation = .horizontal
|
|
tagStyleRow.spacing = 8
|
|
|
|
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)
|
|
checkNowButton.bezelStyle = .rounded
|
|
|
|
stack = NSStackView(views: [launchAtLoginCheckbox, tagStyleRow, autoUpdateCheckbox, checkNowButton])
|
|
stack.orientation = .vertical
|
|
stack.alignment = .leading
|
|
stack.spacing = 14
|
|
stack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(stack)
|
|
NSLayoutConstraint.activate([
|
|
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
stack.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor),
|
|
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
])
|
|
|
|
updatePreferredSize()
|
|
}
|
|
|
|
override func viewDidLayout() {
|
|
super.viewDidLayout()
|
|
updatePreferredSize()
|
|
}
|
|
|
|
// NOT `stack.fittingSize` for the width — that was measured observing
|
|
// it clip a checkbox's label by a consistent amount regardless of
|
|
// when it's read (loadView vs. viewDidLayout: same result), so this
|
|
// isn't a measure-too-early timing issue, it's `NSStackView.fittingSize`
|
|
// itself undercounting a checkbox's true rendered width. Asking each
|
|
// control directly for its own `intrinsicContentSize` instead — which
|
|
// is what AutoLayout itself uses to size an unconstrained control —
|
|
// sidesteps whatever the stack's own aggregation is getting wrong.
|
|
private func updatePreferredSize() {
|
|
// Same reasoning for the tag-style row: summed from its two
|
|
// controls plus the stack spacing rather than trusting the row's
|
|
// own fittingSize.
|
|
let rowWidth = tagStyleLabel.intrinsicContentSize.width
|
|
+ tagStylePopUp.intrinsicContentSize.width + tagStyleRow.spacing
|
|
let widest = [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton]
|
|
.map(\.intrinsicContentSize.width)
|
|
.max().map { max($0, rowWidth) } ?? rowWidth
|
|
preferredContentSize = NSSize(width: widest + 40, height: stack.fittingSize.height)
|
|
}
|
|
|
|
@objc private func changeTagStyle() {
|
|
let index = tagStylePopUp.indexOfSelectedItem
|
|
guard Self.tagStyles.indices.contains(index) else { return }
|
|
WindowOverlayPosition.style = Self.tagStyles[index].style
|
|
onOverlayStyleChanged?()
|
|
}
|
|
|
|
@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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - About tab
|
|
|
|
private let projectAuthor = "bdeshi"
|
|
private let projectHomepage = "https://github.com/bdeshi/shannoncoat"
|
|
|
|
private final class AboutViewController: NSViewController {
|
|
private static let contentWidth: CGFloat = 340
|
|
|
|
override func loadView() {
|
|
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 150))
|
|
|
|
// Centered within a full-width wrapper rather than centered in the
|
|
// stack directly — the stack is leading-aligned (for the text
|
|
// rows below), and its own per-arranged-subview alignment
|
|
// constraint on the icon would conflict with an added centering
|
|
// constraint on the icon itself.
|
|
let icon = NSImageView()
|
|
icon.image = NSApp.applicationIconImage ?? NSImage(named: NSImage.applicationIconName)
|
|
icon.translatesAutoresizingMaskIntoConstraints = false
|
|
let iconContainer = NSView()
|
|
iconContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
iconContainer.addSubview(icon)
|
|
NSLayoutConstraint.activate([
|
|
icon.widthAnchor.constraint(equalToConstant: 96),
|
|
icon.heightAnchor.constraint(equalToConstant: 96),
|
|
icon.topAnchor.constraint(equalTo: iconContainer.topAnchor),
|
|
icon.bottomAnchor.constraint(equalTo: iconContainer.bottomAnchor),
|
|
icon.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
|
|
])
|
|
|
|
let description = NSTextField(wrappingLabelWithString:
|
|
"Runs multiple Claude Desktop profiles side by side, each paired with its own isolated Claude Code config.")
|
|
description.font = .systemFont(ofSize: NSFont.systemFontSize)
|
|
|
|
let authorField = NSTextField(labelWithString: "Author: \(projectAuthor)")
|
|
let sourceField = NSTextField(labelWithString: "Source: \(projectHomepage)")
|
|
let versionField = NSTextField(labelWithString: "Version: \(UpdateChecker.displayVersion)")
|
|
for field in [authorField, sourceField, versionField] {
|
|
field.textColor = .secondaryLabelColor
|
|
field.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
|
}
|
|
|
|
let stack = NSStackView(views: [iconContainer, description, authorField, sourceField, versionField])
|
|
stack.orientation = .vertical
|
|
stack.alignment = .leading
|
|
stack.spacing = 10
|
|
stack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(stack)
|
|
|
|
NSLayoutConstraint.activate([
|
|
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
description.widthAnchor.constraint(equalToConstant: Self.contentWidth),
|
|
iconContainer.widthAnchor.constraint(equalTo: description.widthAnchor),
|
|
])
|
|
|
|
view.layoutSubtreeIfNeeded()
|
|
preferredContentSize = NSSize(width: Self.contentWidth + 40, height: stack.fittingSize.height)
|
|
}
|
|
}
|