diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 270c171..00bae71 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f62eacf..07c2cd4 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3,9 +3,6 @@ // 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() @@ -263,23 +260,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() { diff --git a/Sources/ManageWindow.swift b/Sources/ManageWindow.swift index 0b2df70..7f2d35b 100644 --- a/Sources/ManageWindow.swift +++ b/Sources/ManageWindow.swift @@ -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//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//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) + } +} diff --git a/VERSION b/VERSION index 4e379d2..bcab45a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.2 +0.0.3 diff --git a/build.sh b/build.sh index 4204218..95a1306 100755 --- a/build.sh +++ b/build.sh @@ -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