feat(menubar): CodexBar-faithful Settings shell, binary-flame status icon, resource-bundle packaging

Settings window now replicates CodexBar's System Settings layout
(880x620 fullSizeContentView window, transparent titlebar, fixed 260pt
sidebar, SF Symbol gradient chips, native search field, grouped About
pane with Updates/Links). Status item renders the 0101 flame template
with severity tints. package-app.sh ships the SwiftPM resource bundle,
fixing the Bundle.module launch crash. Sidebar layout modeled on
CodexBar (MIT, steipete/CodexBar).
This commit is contained in:
iamtoruk
2026-08-24 09:22:21 -07:00
parent 831754b8aa
commit 8908bce69e
3 changed files with 371 additions and 122 deletions
+7
View File
@@ -48,6 +48,13 @@ mkdir -p "${BUNDLE}/Contents/Resources"
cp "${BUILT_BINARY}" "${BUNDLE}/Contents/MacOS/${EXECUTABLE_NAME}"
cp "${ICON_SOURCE}" "${BUNDLE}/Contents/Resources/menubar-logo.png"
# SwiftPM emits target resources as a sibling bundle; Bundle.module traps at
# runtime if it is missing from Contents/Resources, so ship it when present.
SPM_RESOURCE_BUNDLE="${BIN_PATH}/${EXECUTABLE_NAME}_${EXECUTABLE_NAME}.bundle"
if [[ -d "${SPM_RESOURCE_BUNDLE}" ]]; then
cp -R "${SPM_RESOURCE_BUNDLE}" "${BUNDLE}/Contents/Resources/"
fi
ICONSET="${DIST_DIR}/AppIcon.iconset"
rm -rf "${ICONSET}"
mkdir -p "${ICONSET}"
+34 -17
View File
@@ -45,6 +45,7 @@ struct CodeBurnApp: App {
Settings {
SettingsView()
.environment(delegate.store)
.environment(delegate.updateChecker)
}
}
}
@@ -948,14 +949,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
statusItem = NSStatusBar.system.statusItem(withLength: statusItemWidth)
guard let button = statusItem.button else { return }
// Set a simple SF Symbol image immediately to ensure the status item renders.
// Set the bundled flame image immediately to ensure the status item renders.
// On macOS Tahoe, status items may fail to appear if only an attributed title
// is set during initial setup.
let flameConfig = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium)
let flame = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")?
.withSymbolConfiguration(flameConfig)
flame?.isTemplate = true
button.image = flame
button.image = Self.menubarFlameImage(tint: nil)
button.imagePosition = .imageLeading
button.target = self
@@ -1009,6 +1006,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
}
}
/// Loads the bundled binary-flame PNG (Resources/ProviderIcons/flame.png) at the
/// menubar text point size. With no tint it stays a template image so the system
/// auto-adapts to the menu bar; a tint returns a recolored non-template copy for
/// the budget/quota warning states.
private static func menubarFlameImage(tint: NSColor?) -> NSImage? {
// SwiftPM keeps the folder hierarchy for .process'd directories, but
// tolerate a flattened layout too so the lookup survives a rule change.
for subdirectory in ["Resources/ProviderIcons", "ProviderIcons", nil] {
guard let url = Bundle.module.url(forResource: "flame", withExtension: "png", subdirectory: subdirectory),
let image = NSImage(contentsOf: url) else { continue }
let aspect = image.size.width / max(image.size.height, 1)
let size = NSSize(width: menubarTitleFontSize * aspect, height: menubarTitleFontSize)
guard let tint else {
image.isTemplate = true
image.size = size
return image
}
let recolored = NSImage(size: size, flipped: false) { rect in
image.draw(in: rect)
tint.set()
rect.fill(using: .sourceAtop)
return true
}
recolored.isTemplate = false
return recolored
}
return nil
}
private func refreshStatusButton() {
guard let button = statusItem.button else { return }
// Skip while the popover is anchored to this button. Rewriting the
@@ -1024,7 +1050,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
button.imagePosition = .noImage
let font = NSFont.monospacedDigitSystemFont(ofSize: menubarTitleFontSize, weight: .regular)
let baseConfig = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium)
// Tint the flame based on the worst-affected connected provider's quota.
// Normal (<70%) keeps the template (auto white-on-dark / black-on-light);
// warning/critical/danger override with a fixed palette color so the
@@ -1034,15 +1059,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
if tint == nil, store.isOverDailyBudget {
tint = NSColor.systemYellow
}
let flameConfig: NSImage.SymbolConfiguration
if let tint {
flameConfig = baseConfig.applying(.init(paletteColors: [tint]))
} else {
flameConfig = baseConfig
}
let flame = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")?
.withSymbolConfiguration(flameConfig)
flame?.isTemplate = (tint == nil)
let flame = Self.menubarFlameImage(tint: tint)
let attachment = NSTextAttachment()
attachment.image = flame
@@ -1321,7 +1338,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
}
let hosting = NSHostingController(
rootView: SettingsView().environment(store)
rootView: SettingsView().environment(store).environment(updateChecker)
)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 520, height: 380),
@@ -57,41 +57,67 @@ struct SettingsView: View {
)
}
private static let windowWidth: CGFloat = 880
private static let windowHeight: CGFloat = 620
private static let sidebarWidth: CGFloat = 260
var body: some View {
NavigationSplitView {
VStack(spacing: 0) {
TextField("Search providers", text: $searchText)
.textFieldStyle(.roundedBorder)
.padding(.horizontal, 12)
.padding(.vertical, 10)
List(selection: selection) {
Section {
SidebarTileRow(icon: "general", tile: Color(nsColor: .systemGray), title: "General")
.tag("general")
SidebarTileRow(icon: "about", tile: Color(white: 0.13), title: "About")
.tag("about")
HStack(spacing: 0) {
// Layout modeled on CodexBar (MIT, steipete/CodexBar): a fixed-width
// sidebar (search, General/About, per-provider rows) over an
// edge-to-edge sidebar material, hairline divider, then the detail pane.
sidebar
.frame(width: Self.sidebarWidth)
.background {
SettingsSidebarMaterial()
.ignoresSafeArea()
}
Divider()
.ignoresSafeArea()
detailView
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
.frame(width: Self.windowWidth, height: Self.windowHeight)
.background {
SettingsWindowStyleAccessor()
.allowsHitTesting(false)
}
}
private var sidebar: some View {
VStack(spacing: 0) {
SettingsSidebarSearchField(searchText: $searchText)
.padding(.horizontal, 8)
.padding(.top, 16)
.padding(.bottom, 8)
List(selection: selection) {
Section {
SettingsSidebarPaneRow(pane: "general", title: "General", systemImage: "gearshape.fill", color: .gray)
SettingsSidebarAboutRow()
}
Section {
ForEach(filteredProviders) { provider in
SettingsSidebarProviderRow(provider: provider)
.tag(provider.id)
}
Section {
ForEach(filteredProviders) { provider in
ProviderSidebarRow(provider: provider)
.tag(provider.id)
}
} header: {
HStack {
Text("Providers")
Spacer()
Text("\(providers.filter(\.isConnected).count) on")
.foregroundStyle(.secondary)
}
} header: {
HStack(spacing: 4) {
Text("Providers")
Spacer()
Text("\(providers.filter(\.isConnected).count) on")
.foregroundStyle(.tertiary)
.monospacedDigit()
.padding(.trailing, 10)
}
}
.listStyle(.sidebar)
}
.navigationSplitViewColumnWidth(min: 230, ideal: 230, max: 230)
} detail: {
detailView
.listStyle(.sidebar)
.scrollContentBackground(.hidden)
}
.frame(width: 860, height: 620)
.padding(.horizontal, 8)
}
@ViewBuilder
@@ -111,33 +137,14 @@ struct SettingsView: View {
// MARK: - Sidebar support
/// Loads one of the bundled 512px black-glyph PNGs (Resources/ProviderIcons)
/// as a template NSImage so `tint` fully controls the rendered color.
private struct ProviderIconView: View {
let name: String
let tint: Color
var body: some View {
if let image = ProviderIconCache.image(named: name) {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundStyle(tint)
} else {
Image(systemName: "app.dashed")
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundStyle(tint)
}
}
}
/// PNG decode is too expensive to redo on every hover-driven body evaluation,
/// so loaded template images are kept for the life of the process.
/// PNG decode is too expensive to redo on every body evaluation, so loaded
/// template images are kept for the life of the process.
@MainActor
private enum ProviderIconCache {
private static var images: [String: NSImage] = [:]
/// Loads one of the bundled 512px black-glyph PNGs (Resources/ProviderIcons)
/// as a template NSImage so `foregroundStyle` fully controls the color.
static func image(named name: String) -> NSImage? {
if let cached = images[name] { return cached }
// SwiftPM keeps the folder hierarchy for .process'd directories, but
@@ -154,48 +161,189 @@ private enum ProviderIconCache {
}
}
/// General/About rows mimic System Settings: a fixed-color rounded square
/// holding the white template glyph.
private struct SidebarTileRow: View {
let icon: String
let tile: Color
let title: String
/// Colored rounded-square symbol used for app panes in the settings sidebar,
/// mirroring the System Settings sidebar style.
private struct SettingsIconChip: View {
static let side: CGFloat = 20
let systemImage: String
let color: Color
var body: some View {
HStack(spacing: 8) {
ProviderIconView(name: icon, tint: .white)
.frame(width: 15, height: 15)
.frame(width: 24, height: 24)
.background(RoundedRectangle(cornerRadius: 5).fill(tile))
Text(title)
}
Image(systemName: systemImage)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.white)
.frame(width: Self.side, height: Self.side)
.background(
RoundedRectangle(cornerRadius: 5, style: .continuous)
.fill(LinearGradient(
colors: [color.opacity(0.85), color],
startPoint: .top,
endPoint: .bottom)))
.accessibilityHidden(true)
}
}
/// Provider rows carry no tile; the logo sits gray at rest and picks up the
/// current accent color while hovered. A green dot marks connected panes.
private struct ProviderSidebarRow: View {
@Environment(AppStore.self) private var store
let provider: SettingsView.ProviderPane
@State private var isHovered = false
private struct SettingsSidebarPaneRow: View {
let pane: String
let title: String
let systemImage: String
let color: Color
var body: some View {
HStack(spacing: 8) {
ProviderIconView(
name: provider.icon,
tint: isHovered ? store.accentPreset.base : .secondary
)
.frame(width: 18, height: 18)
SettingsIconChip(systemImage: systemImage, color: color)
Text(title)
}
.tag(pane)
}
}
private struct SettingsSidebarAboutRow: View {
var body: some View {
HStack(spacing: 8) {
if let icon = NSApplication.shared.applicationIconImage {
Image(nsImage: icon)
.resizable()
.scaledToFit()
.frame(width: SettingsIconChip.side, height: SettingsIconChip.side)
.accessibilityHidden(true)
} else {
SettingsIconChip(systemImage: "info.circle.fill", color: .green)
}
Text("About")
}
.tag("about")
}
}
private struct SettingsSidebarProviderRow: View {
let provider: SettingsView.ProviderPane
var body: some View {
HStack(spacing: 8) {
SettingsSidebarBrandIcon(icon: provider.icon, isConnected: provider.isConnected)
Text(provider.name)
Spacer()
.foregroundStyle(provider.isConnected ? .primary : .secondary)
Spacer(minLength: 4)
if provider.isConnected {
Circle()
.fill(.green)
.frame(width: 6, height: 6)
.accessibilityHidden(true)
}
}
.contentShape(Rectangle())
.onHover { isHovered = $0 }
.opacity(provider.isConnected ? 1 : 0.62)
}
}
private struct SettingsSidebarBrandIcon: View {
let icon: String
let isConnected: Bool
var body: some View {
Group {
if let image = ProviderIconCache.image(named: icon) {
Image(nsImage: image)
.resizable()
.scaledToFit()
} else {
Image(systemName: "circle.dotted")
.resizable()
.scaledToFit()
}
}
.frame(width: 16, height: 16)
.foregroundStyle(isConnected ? .primary : .secondary)
.accessibilityHidden(true)
}
}
private struct SettingsSidebarSearchField: View {
@Binding var searchText: String
var body: some View {
HStack(spacing: 5) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
.accessibilityHidden(true)
TextField("Search providers", text: $searchText)
.textFieldStyle(.plain)
if !searchText.isEmpty {
Button {
searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
.accessibilityLabel("Clear")
}
.buttonStyle(.plain)
}
}
.font(.callout)
.padding(.horizontal, 7)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color(nsColor: .textBackgroundColor).opacity(0.6)))
.overlay(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.stroke(Color(nsColor: .separatorColor).opacity(0.6), lineWidth: 1))
}
}
/// Edge-to-edge sidebar material so the sidebar runs up behind the transparent
/// titlebar, matching System Settings.
private struct SettingsSidebarMaterial: NSViewRepresentable {
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
configure(view)
return view
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
configure(nsView)
}
private func configure(_ view: NSVisualEffectView) {
view.material = .sidebar
view.blendingMode = .behindWindow
view.state = .followsWindowActiveState
}
}
/// Applies the System Settings window chrome (transparent, separator-less
/// titlebar over full-size content) to whichever window hosts this view.
/// Needed because the SwiftUI Settings scene exposes no styling hooks.
private struct SettingsWindowStyleAccessor: NSViewRepresentable {
func makeNSView(context: Context) -> SettingsWindowStyleView {
SettingsWindowStyleView()
}
func updateNSView(_ nsView: SettingsWindowStyleView, context: Context) {
nsView.applyStyle()
}
}
private final class SettingsWindowStyleView: NSView {
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
applyStyle()
}
func applyStyle() {
guard let window else { return }
// Full-size content lets the sidebar material extend behind the
// titlebar so the edge-to-edge sidebar reaches the top of the window.
window.titlebarAppearsTransparent = true
window.titleVisibility = .visible
window.titlebarSeparatorStyle = .none
window.styleMask.insert(.fullSizeContentView)
}
}
@@ -1252,36 +1400,113 @@ private struct DevinSettingsTab: View {
// MARK: - About
private struct AboutSettingsTab: View {
private let appVersion: String = AppVersion.normalizedBundleShortVersion
private let buildVersion: String = AppVersion.normalizedBundleBuildVersion
@Environment(UpdateChecker.self) private var updateChecker
private var versionString: String {
let version = AppVersion.normalizedBundleShortVersion
let build = AppVersion.normalizedBundleBuildVersion
return "\(version) (\(build))"
}
var body: some View {
VStack(spacing: 14) {
Image(systemName: "flame.fill")
.font(.system(size: 40))
.foregroundStyle(Theme.brandAccent)
Text("CodeBurn")
.font(.system(size: 18, weight: .semibold))
Text("AI Coding Cost Tracker")
.font(.system(size: 12))
.foregroundStyle(.secondary)
Text("Version \(appVersion) (\(buildVersion))")
.font(.codeMono(size: 11))
.foregroundStyle(.secondary)
Link(destination: URL(string: "https://github.com/getagentseal/codeburn")!) {
Label("Star on GitHub", systemImage: "star.fill")
.font(.system(size: 12, weight: .medium))
Form {
Section {
hero
.frame(maxWidth: .infinity)
.listRowBackground(Color.clear)
}
.buttonStyle(.borderedProminent)
.tint(Theme.brandAccent)
HStack(spacing: 10) {
Link("GitHub", destination: URL(string: "https://github.com/getagentseal/codeburn")!)
Link("Issues", destination: URL(string: "https://github.com/getagentseal/codeburn/issues")!)
Link("Sponsor", destination: URL(string: "https://github.com/sponsors/iamtoruk")!)
Section {
LabeledContent("Version \(versionString)") {
Button("Check for Updates") {
Task { await updateChecker.check() }
}
}
if let error = updateChecker.updateError {
Text(error)
.font(.footnote)
.foregroundStyle(.secondary)
} else if updateChecker.updateAvailable, let latest = updateChecker.latestVersion {
Text("\(AppVersion.display(latest)) is available — choose Check for Updates in the CodeBurn menu to install it.")
.font(.footnote)
.foregroundStyle(.secondary)
}
} header: {
Text("Updates")
}
Section {
AboutLinkRow(
icon: "chevron.left.slash.chevron.right",
title: "GitHub",
url: "https://github.com/getagentseal/codeburn")
AboutLinkRow(
icon: "globe",
title: "Website",
url: "https://codeburn.app")
AboutLinkRow(
icon: "exclamationmark.bubble",
title: "Issues",
url: "https://github.com/getagentseal/codeburn/issues")
} header: {
Text("Links")
} footer: {
Text("© AgentSeal")
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
}
.font(.system(size: 12))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.formStyle(.grouped)
.scrollContentBackground(.hidden)
}
private var hero: some View {
VStack(spacing: 10) {
if let icon = NSApplication.shared.applicationIconImage {
Image(nsImage: icon)
.resizable()
.frame(width: 64, height: 64)
.cornerRadius(12)
}
VStack(spacing: 2) {
Text("CodeBurn")
.font(.title3).fontWeight(.semibold)
Text("Version \(versionString)")
.foregroundStyle(.secondary)
Text("AI Coding Cost Tracker")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 6)
}
}
private struct AboutLinkRow: View {
let icon: String
let title: String
let url: String
@State private var hovering = false
var body: some View {
Button {
if let url = URL(string: self.url) { NSWorkspace.shared.open(url) }
} label: {
HStack(spacing: 8) {
Image(systemName: icon)
.frame(width: 18)
.foregroundStyle(.secondary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundStyle(hovering ? Color.accentColor : Color.secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hovering = $0 }
}
}