Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d3a539edd | ||
|
|
9673dc2792 |
@@ -28,6 +28,12 @@ jobs:
|
||||
|
||||
- name: Build shannoncoat.app
|
||||
run: ./build.sh
|
||||
env:
|
||||
# This workflow only ever runs against a tag (see the trigger
|
||||
# comment above) — told to build.sh directly rather than having
|
||||
# it re-derive that via `git describe`, which needs full tag
|
||||
# refs a CI runner's checkout isn't guaranteed to have fetched.
|
||||
SHANNONCOAT_RELEASE_BUILD: "1"
|
||||
|
||||
- name: Zip app bundle
|
||||
run: ditto -c -k --sequesterRsrc --keepParent ".build/shannoncoat.app" "shannoncoat.app.zip"
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
icon/claude-src/
|
||||
icon/AppIcon.iconset/
|
||||
|
||||
# Session handoff notes — local only, never committed
|
||||
CHECKPOINT.md
|
||||
|
||||
# Local reference material (e.g. nested checkouts of other projects) — not
|
||||
# part of this project, never meant to be committed.
|
||||
.scratch/
|
||||
|
||||
+37
-19
@@ -3,15 +3,14 @@
|
||||
// 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
|
||||
private var windowOverlays: [String: WindowOverlay] = [:]
|
||||
private var pendingWindowOverlays: Set<String> = []
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
menu.delegate = self
|
||||
@@ -53,6 +52,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
|
||||
currentInfos = infos
|
||||
applyTitle(infos)
|
||||
ManageWindowController.shared.update(infos)
|
||||
updateWindowOverlays(infos)
|
||||
}
|
||||
|
||||
// Shown on every running *managed* profile — never on "default",
|
||||
// which isn't a shannoncoat profile at all, just the real underlying
|
||||
// Claude install. Removing an entry here deinits its WindowOverlay,
|
||||
// which tears down its AXObserver and hides the tag panel.
|
||||
private func updateWindowOverlays(_ infos: [ProfileInfo]) {
|
||||
let managed = infos.filter { $0.running && $0.profile.name != "default" }
|
||||
let managedNames = Set(managed.map(\.profile.name))
|
||||
windowOverlays = windowOverlays.filter { managedNames.contains($0.key) }
|
||||
pendingWindowOverlays.formIntersection(managedNames)
|
||||
|
||||
for info in managed
|
||||
where windowOverlays[info.profile.name] == nil && !pendingWindowOverlays.contains(info.profile.name) {
|
||||
guard let pid = info.pid else { continue }
|
||||
let name = info.profile.name
|
||||
pendingWindowOverlays.insert(name)
|
||||
WindowOverlay.attach(profileName: name, color: ProfileColor.dotColor(for: name), pid: pid) { [weak self] overlay in
|
||||
guard let self else { return }
|
||||
pendingWindowOverlays.remove(name)
|
||||
// The profile may have quit again while this was retrying
|
||||
// for its window — don't attach a stale overlay if so.
|
||||
guard currentInfos.contains(where: { $0.profile.name == name && $0.running }) else { return }
|
||||
windowOverlays[name] = overlay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two profiles sharing a dir is a fatal misconfiguration (would
|
||||
@@ -263,23 +289,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
|
||||
ManageWindowController.shared.show()
|
||||
}
|
||||
|
||||
// About lives as a tab in the same window (see ManageWindow.swift)
|
||||
// rather than the standard NSApp About panel — the standard panel
|
||||
// auto-appends its own "Version X (Y)" line from Info.plist's
|
||||
// CFBundleVersion/CFBundleShortVersionString, which are identical in
|
||||
// this build, so it was showing that duplicated alongside our own
|
||||
// version line.
|
||||
@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.displayVersion)
|
||||
"""
|
||||
NSApp.orderFrontStandardAboutPanel(options: [
|
||||
.applicationName: "shannoncoat",
|
||||
.credits: NSAttributedString(string: credits),
|
||||
])
|
||||
ManageWindowController.shared.update(currentInfos)
|
||||
ManageWindowController.shared.show(tab: .about)
|
||||
}
|
||||
|
||||
@objc func quitClaude() {
|
||||
|
||||
+273
-45
@@ -1,14 +1,21 @@
|
||||
// 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.
|
||||
// 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
|
||||
@@ -19,18 +26,19 @@ final class ManageWindowController: NSWindowController {
|
||||
|
||||
private init() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 460, height: 360),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
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()
|
||||
|
||||
let tabs = NSTabViewController()
|
||||
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)
|
||||
@@ -38,8 +46,11 @@ final class ManageWindowController: NSWindowController {
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func show() {
|
||||
func show(tab: Tab? = nil) {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if let tab {
|
||||
tabs.selectedTabViewItemIndex = tab.rawValue
|
||||
}
|
||||
window?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
@@ -57,41 +68,60 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
|
||||
|
||||
private var infos: [ProfileInfo] = []
|
||||
private let tableView = NSTableView()
|
||||
private let removeButton = NSButton(title: "\u{2212}", target: nil, action: nil)
|
||||
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: 460, height: 360))
|
||||
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 260))
|
||||
|
||||
let column = NSTableColumn(identifier: .init("profile"))
|
||||
column.title = "Profile"
|
||||
column.width = 380
|
||||
column.width = 340
|
||||
tableView.addTableColumn(column)
|
||||
tableView.headerView = nil
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
tableView.rowHeight = 22
|
||||
tableView.rowHeight = 24
|
||||
tableView.usesAlternatingRowBackgroundColors = true
|
||||
tableView.menu = buildContextMenu()
|
||||
|
||||
let scroll = NSScrollView()
|
||||
scroll.documentView = tableView
|
||||
scroll.hasVerticalScroller = true
|
||||
scroll.borderType = .bezelBorder
|
||||
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()])
|
||||
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.spacing = 4
|
||||
buttonRow.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
buildDetailStack()
|
||||
@@ -102,44 +132,102 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
|
||||
addForm.isHidden = true
|
||||
addForm.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let stack = NSStackView(views: [scroll, detailStack, buttonRow, addForm])
|
||||
let stack = NSStackView(views: [scroll, buttonRow, detailStack, addForm])
|
||||
stack.orientation = .vertical
|
||||
stack.spacing = 8
|
||||
stack.edgeInsets = NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
|
||||
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.heightAnchor.constraint(greaterThanOrEqualToConstant: 160),
|
||||
detailStack.widthAnchor.constraint(equalTo: stack.widthAnchor),
|
||||
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.
|
||||
// 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() {
|
||||
for value in [detailAppValue, detailCodeValue] {
|
||||
value.isSelectable = true
|
||||
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 = 6
|
||||
appRow.spacing = 8
|
||||
let codeRow = NSStackView(views: [labeled("Claude Code:"), detailCodeValue])
|
||||
codeRow.orientation = .horizontal
|
||||
codeRow.spacing = 6
|
||||
codeRow.spacing = 8
|
||||
|
||||
detailStack.orientation = .vertical
|
||||
detailStack.alignment = .leading
|
||||
detailStack.spacing = 2
|
||||
detailStack.spacing = 4
|
||||
[appRow, codeRow].forEach(detailStack.addArrangedSubview)
|
||||
appRow.widthAnchor.constraint(equalTo: detailStack.widthAnchor).isActive = true
|
||||
codeRow.widthAnchor.constraint(equalTo: detailStack.widthAnchor).isActive = true
|
||||
@@ -149,62 +237,93 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
|
||||
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 = 6
|
||||
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 = 6
|
||||
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 buttonRow = NSStackView(views: [NSView(), cancelButton, createButton])
|
||||
buttonRow.orientation = .horizontal
|
||||
buttonRow.spacing = 8
|
||||
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 = 6
|
||||
[nameField, codeRow, appRow, errorLabel, buttonRow].forEach(addForm.addArrangedSubview)
|
||||
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
|
||||
buttonRow.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 {
|
||||
removeButton.isEnabled = false
|
||||
addRemoveControl.setEnabled(false, forSegment: 1)
|
||||
detailStack.isHidden = true
|
||||
updatePreferredSize()
|
||||
return
|
||||
}
|
||||
let profile = infos[row].profile
|
||||
removeButton.isEnabled = profile.name != "default"
|
||||
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
|
||||
@@ -229,17 +348,26 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
|
||||
cell.imageView = imageView
|
||||
cell.textField = textField
|
||||
NSLayoutConstraint.activate([
|
||||
imageView.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4),
|
||||
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: 6),
|
||||
textField.trailingAnchor.constraint(lessThanOrEqualTo: cell.trailingAnchor, constant: -4),
|
||||
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)
|
||||
cell.textField?.stringValue = "\(info.profile.name) — \(info.running ? "running" : "stopped")"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -258,6 +386,14 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
|
||||
|
||||
// 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 {
|
||||
@@ -267,6 +403,7 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
|
||||
errorLabel.stringValue = ""
|
||||
view.window?.makeFirstResponder(nameField)
|
||||
}
|
||||
updatePreferredSize()
|
||||
}
|
||||
|
||||
@objc private func chooseCodeDir() { choose(into: codeField) }
|
||||
@@ -295,6 +432,7 @@ private final class ProfilesViewController: NSViewController, NSTableViewDataSou
|
||||
onChange?()
|
||||
} catch {
|
||||
errorLabel.stringValue = (error as? LocalizedError)?.errorDescription ?? "\(error)"
|
||||
updatePreferredSize()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,9 +502,10 @@ private final class SettingsViewController: NSViewController {
|
||||
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 var stack: NSStackView!
|
||||
|
||||
override func loadView() {
|
||||
view = NSView(frame: NSRect(x: 0, y: 0, width: 460, height: 360))
|
||||
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 150))
|
||||
|
||||
launchAtLoginCheckbox.target = self
|
||||
launchAtLoginCheckbox.action = #selector(toggleLaunchAtLogin)
|
||||
@@ -378,17 +517,43 @@ private final class SettingsViewController: NSViewController {
|
||||
|
||||
checkNowButton.target = self
|
||||
checkNowButton.action = #selector(checkNow)
|
||||
checkNowButton.bezelStyle = .rounded
|
||||
|
||||
let stack = NSStackView(views: [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton])
|
||||
stack = NSStackView(views: [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton])
|
||||
stack.orientation = .vertical
|
||||
stack.alignment = .leading
|
||||
stack.spacing = 12
|
||||
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, constant: 20),
|
||||
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||
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() {
|
||||
let widest = [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton]
|
||||
.map(\.intrinsicContentSize.width)
|
||||
.max() ?? 200
|
||||
preferredContentSize = NSSize(width: widest + 40, height: stack.fittingSize.height)
|
||||
}
|
||||
|
||||
@objc private func toggleLaunchAtLogin() {
|
||||
@@ -432,3 +597,66 @@ private final class SettingsViewController: NSViewController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// A small floating tag pinned to a Claude window's corner, showing which
|
||||
// profile it belongs to — every profile launches the same app with the
|
||||
// same icon and title, so there's otherwise no way to tell two open
|
||||
// Claude windows apart at a glance. Kept in sync live via AXObserver
|
||||
// notifications (move/resize/miniaturize/destroy) rather than polling —
|
||||
// only a persistent process can hold a watcher like this at all, which is
|
||||
// exactly the gap the old one-shot CLI script couldn't close.
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
|
||||
// The tag's position relative to its window's top-right corner, shared
|
||||
// across every open tag and persisted so a drag survives a relaunch.
|
||||
// Anchored from the right/top edges (not left) because that's the corner
|
||||
// that stays put under the common resize gesture (dragging the bottom-right
|
||||
// handle) — an offset-from-left would drift away from a user's chosen spot
|
||||
// every time the window changed width.
|
||||
enum WindowOverlayPosition {
|
||||
private static let rightInsetKey = "WindowOverlayRightInset"
|
||||
private static let topInsetKey = "WindowOverlayTopInset"
|
||||
|
||||
// Top-right by default — top-left is where every window's close/
|
||||
// minimize/zoom controls live, which the tag must never sit over.
|
||||
static let defaultRightInset: CGFloat = 10
|
||||
static let defaultTopInset: CGFloat = 6
|
||||
|
||||
static var rightInset: CGFloat {
|
||||
get { (UserDefaults.standard.object(forKey: rightInsetKey) as? Double).map { CGFloat($0) } ?? defaultRightInset }
|
||||
set { UserDefaults.standard.set(Double(newValue), forKey: rightInsetKey) }
|
||||
}
|
||||
|
||||
static var topInset: CGFloat {
|
||||
get { (UserDefaults.standard.object(forKey: topInsetKey) as? Double).map { CGFloat($0) } ?? defaultTopInset }
|
||||
set { UserDefaults.standard.set(Double(newValue), forKey: topInsetKey) }
|
||||
}
|
||||
}
|
||||
|
||||
final class WindowOverlay: NSObject, NSWindowDelegate {
|
||||
let pid: pid_t
|
||||
private let axWindow: AXUIElement
|
||||
private var observer: AXObserver?
|
||||
private let panel: NSPanel
|
||||
|
||||
private static let tagSize = NSSize(width: 110, height: 20)
|
||||
|
||||
init?(profileName: String, color: NSColor, pid: pid_t) {
|
||||
let axApp = AXUIElementCreateApplication(pid)
|
||||
guard let window = Self.firstWindow(of: axApp) else { return nil }
|
||||
self.pid = pid
|
||||
self.axWindow = window
|
||||
|
||||
let panel = NSPanel(
|
||||
contentRect: NSRect(origin: .zero, size: Self.tagSize),
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered, defer: false)
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
panel.hasShadow = true
|
||||
panel.level = .floating
|
||||
// Draggable (so it can be moved off whatever it's obstructing),
|
||||
// but .nonactivatingPanel keeps a click/drag from stealing focus
|
||||
// away from the Claude window underneath.
|
||||
panel.isMovableByWindowBackground = true
|
||||
panel.collectionBehavior = [.stationary, .ignoresCycle]
|
||||
|
||||
let background = NSView(frame: NSRect(origin: .zero, size: Self.tagSize))
|
||||
background.wantsLayer = true
|
||||
background.layer?.backgroundColor = color.cgColor
|
||||
background.layer?.cornerRadius = 5
|
||||
|
||||
let label = NSTextField(labelWithString: profileName)
|
||||
label.frame = NSRect(origin: .zero, size: Self.tagSize)
|
||||
label.alignment = .center
|
||||
label.font = .systemFont(ofSize: 11, weight: .semibold)
|
||||
label.textColor = .white
|
||||
label.drawsBackground = false
|
||||
label.lineBreakMode = .byTruncatingTail
|
||||
background.addSubview(label)
|
||||
|
||||
panel.contentView = background
|
||||
self.panel = panel
|
||||
super.init()
|
||||
panel.delegate = self
|
||||
|
||||
guard reposition() else { return nil }
|
||||
startObserving()
|
||||
}
|
||||
|
||||
// A cold Electron launch can take a second or more between the
|
||||
// process starting (which is what triggers `updateWindowOverlays`,
|
||||
// via NSWorkspace's launch notification) and its first window
|
||||
// actually existing — the same race ClaudeControl.focus's
|
||||
// waitForWindow already handles for the same reason. `init?` can't
|
||||
// retry on its own (once it returns nil, that attempt is done), so
|
||||
// this polls it every quarter second for up to ~3s before giving up.
|
||||
static func attach(
|
||||
profileName: String, color: NSColor, pid: pid_t, attemptsRemaining: Int = 12,
|
||||
completion: @escaping (WindowOverlay?) -> Void
|
||||
) {
|
||||
if let overlay = WindowOverlay(profileName: profileName, color: color, pid: pid) {
|
||||
completion(overlay)
|
||||
} else if attemptsRemaining > 0 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
attach(
|
||||
profileName: profileName, color: color, pid: pid, attemptsRemaining: attemptsRemaining - 1,
|
||||
completion: completion)
|
||||
}
|
||||
} else {
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
stopObserving()
|
||||
panel.orderOut(nil)
|
||||
}
|
||||
|
||||
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 frame(of window: AXUIElement) -> CGRect? {
|
||||
var posValue: CFTypeRef?
|
||||
var sizeValue: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(window, kAXPositionAttribute as CFString, &posValue) == .success,
|
||||
AXUIElementCopyAttributeValue(window, kAXSizeAttribute as CFString, &sizeValue) == .success,
|
||||
CFGetTypeID(posValue) == AXValueGetTypeID(), CFGetTypeID(sizeValue) == AXValueGetTypeID()
|
||||
else { return nil }
|
||||
var point = CGPoint.zero
|
||||
var size = CGSize.zero
|
||||
guard AXValueGetValue(posValue as! AXValue, .cgPoint, &point),
|
||||
AXValueGetValue(sizeValue as! AXValue, .cgSize, &size)
|
||||
else { return nil }
|
||||
return CGRect(origin: point, size: size)
|
||||
}
|
||||
|
||||
private static func screen(containing axFrame: CGRect) -> NSScreen? {
|
||||
NSScreen.screens.first { $0.frame.minX <= axFrame.midX && axFrame.midX <= $0.frame.maxX } ?? NSScreen.main
|
||||
}
|
||||
|
||||
// AX coordinates are top-left-origin (screen top = y 0); AppKit screen
|
||||
// coordinates are bottom-left-origin. Returns the window's frame
|
||||
// translated into AppKit's space.
|
||||
private static func appKitFrame(of axFrame: CGRect, on screen: NSScreen) -> NSRect {
|
||||
NSRect(x: axFrame.origin.x, y: screen.frame.maxY - axFrame.origin.y - axFrame.height,
|
||||
width: axFrame.width, height: axFrame.height)
|
||||
}
|
||||
|
||||
// Positions the tag using the persisted (or default) offset from the
|
||||
// window's top-right corner. Returns false (and hides the tag) if the
|
||||
// window has no readable frame right now — minimized, or Accessibility
|
||||
// not granted.
|
||||
@discardableResult
|
||||
private func reposition() -> Bool {
|
||||
guard let axFrame = Self.frame(of: axWindow), let screen = Self.screen(containing: axFrame) else {
|
||||
panel.orderOut(nil)
|
||||
return false
|
||||
}
|
||||
let windowFrame = Self.appKitFrame(of: axFrame, on: screen)
|
||||
let origin = NSPoint(
|
||||
x: windowFrame.maxX - WindowOverlayPosition.rightInset - Self.tagSize.width,
|
||||
y: windowFrame.maxY - WindowOverlayPosition.topInset - Self.tagSize.height)
|
||||
panel.setFrameOrigin(origin)
|
||||
if !panel.isVisible { panel.orderFrontRegardless() }
|
||||
return true
|
||||
}
|
||||
|
||||
// Fires for every panel move — ours (from `reposition`, when the
|
||||
// tracked window itself moves/resizes) and the user's (dragging the
|
||||
// tag). Either way, re-deriving the offset from the panel's actual
|
||||
// current position and persisting it is correct: our own moves just
|
||||
// recompute the same offset they were placed with, a no-op; a user
|
||||
// drag captures the new spot they chose so it survives the next
|
||||
// reposition and the next launch. Clamped to stay within the window
|
||||
// so a sloppy drag can't wander off it entirely.
|
||||
func windowDidMove(_ notification: Notification) {
|
||||
guard let axFrame = Self.frame(of: axWindow), let screen = Self.screen(containing: axFrame) else { return }
|
||||
let windowFrame = Self.appKitFrame(of: axFrame, on: screen)
|
||||
let panelOrigin = panel.frame.origin
|
||||
let rightInset = (windowFrame.maxX - (panelOrigin.x + Self.tagSize.width))
|
||||
.clamped(to: 0...(max(0, windowFrame.width - Self.tagSize.width)))
|
||||
let topInset = (windowFrame.maxY - (panelOrigin.y + Self.tagSize.height))
|
||||
.clamped(to: 0...(max(0, windowFrame.height - Self.tagSize.height)))
|
||||
WindowOverlayPosition.rightInset = rightInset
|
||||
WindowOverlayPosition.topInset = topInset
|
||||
}
|
||||
|
||||
// MARK: - Live tracking
|
||||
|
||||
private func startObserving() {
|
||||
var newObserver: AXObserver?
|
||||
guard AXObserverCreate(pid, WindowOverlay.axCallback, &newObserver) == .success, let newObserver
|
||||
else { return }
|
||||
observer = newObserver
|
||||
|
||||
let refcon = Unmanaged.passUnretained(self).toOpaque()
|
||||
for name in [
|
||||
kAXMovedNotification, kAXResizedNotification, kAXUIElementDestroyedNotification,
|
||||
kAXWindowMiniaturizedNotification, kAXWindowDeminiaturizedNotification,
|
||||
] {
|
||||
AXObserverAddNotification(newObserver, axWindow, name as CFString, refcon)
|
||||
}
|
||||
CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(newObserver), .defaultMode)
|
||||
}
|
||||
|
||||
private func stopObserving() {
|
||||
guard let observer else { return }
|
||||
CFRunLoopRemoveSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(observer), .defaultMode)
|
||||
self.observer = nil
|
||||
}
|
||||
|
||||
private static let axCallback: AXObserverCallback = { _, _, notification, refcon in
|
||||
guard let refcon else { return }
|
||||
let overlay = Unmanaged<WindowOverlay>.fromOpaque(refcon).takeUnretainedValue()
|
||||
switch notification as String {
|
||||
case kAXUIElementDestroyedNotification, kAXWindowMiniaturizedNotification:
|
||||
overlay.panel.orderOut(nil)
|
||||
default:
|
||||
overlay.reposition()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Comparable {
|
||||
func clamped(to range: ClosedRange<Self>) -> Self {
|
||||
min(max(self, range.lowerBound), range.upperBound)
|
||||
}
|
||||
}
|
||||
@@ -54,12 +54,21 @@ command -v swiftc >/dev/null || { echo "error: swiftc not found (install Xcode C
|
||||
# Info.plist above.
|
||||
cp "$VERSION_FILE" "$APP/Contents/Resources/VERSION"
|
||||
|
||||
# COMMIT is the short SHA the About panel appends to the version, so a dev
|
||||
# COMMIT is the short SHA the About tab appends to the version, so one dev
|
||||
# build is distinguishable from another — left empty for a real release
|
||||
# build (HEAD sitting exactly on a tag, which is how release.yml checks
|
||||
# this out), since the version number alone is unambiguous there.
|
||||
# build, since the version number alone is unambiguous there. A release
|
||||
# build is one of:
|
||||
# - SHANNONCOAT_RELEASE_BUILD set by release.yml, which already knows
|
||||
# for certain whether it's building a tag — trusted outright rather
|
||||
# than re-derived, since `git describe` needs full tag refs that a
|
||||
# CI runner's shallow checkout isn't guaranteed to have fetched.
|
||||
# - a local/manual build with HEAD sitting exactly on a tag (checked
|
||||
# via `git describe`, which is reliable here since a normal
|
||||
# non-shallow local clone has its full tag history).
|
||||
GIT_SHA=""
|
||||
if git -C "$SCRIPT_DIR" rev-parse --git-dir >/dev/null 2>&1 \
|
||||
if [[ -n "${SHANNONCOAT_RELEASE_BUILD:-}" ]]; then
|
||||
: # release build — leave GIT_SHA empty
|
||||
elif git -C "$SCRIPT_DIR" rev-parse --git-dir >/dev/null 2>&1 \
|
||||
&& ! git -C "$SCRIPT_DIR" describe --tags --exact-match >/dev/null 2>&1; then
|
||||
GIT_SHA="$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user