Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions mac/Scripts/package-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
set -euo pipefail

VERSION="${1:-dev}"
ASSET_VERSION="${VERSION#mac-}"
BUNDLE_VERSION="${ASSET_VERSION#v}"
BUNDLE_NAME="CodeBurnMenubar.app"
BUNDLE_ID="org.agentseal.codeburn-menubar"
EXECUTABLE_NAME="CodeBurnMenubar"
Expand Down Expand Up @@ -66,9 +68,9 @@ cat > "${BUNDLE}/Contents/Info.plist" <<PLIST
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION}</string>
<string>${BUNDLE_VERSION}</string>
<key>CFBundleVersion</key>
<string>${VERSION}</string>
<string>${BUNDLE_VERSION}</string>
<key>LSMinimumSystemVersion</key>
<string>${MIN_MACOS}</string>
<key>LSUIElement</key>
Expand All @@ -93,7 +95,7 @@ echo "▸ Ad-hoc signing..."
codesign --force --sign - --timestamp=none --deep "${BUNDLE}" 2>/dev/null || true
codesign --verify --deep --strict "${BUNDLE}" 2>/dev/null || echo " (signature verify skipped)"

ZIP_NAME="CodeBurnMenubar-${VERSION}.zip"
ZIP_NAME="CodeBurnMenubar-${ASSET_VERSION}.zip"
ZIP_PATH="${DIST_DIR}/${ZIP_NAME}"
echo "▸ Packaging ${ZIP_NAME}..."
(cd "${DIST_DIR}" && COPYFILE_DISABLE=1 /usr/bin/ditto -c -k --norsrc --keepParent "${BUNDLE_NAME}" "${ZIP_NAME}")
Expand Down
66 changes: 66 additions & 0 deletions mac/Sources/CodeBurnMenubar/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Foundation
import Observation

private let cacheTTLSeconds: TimeInterval = 30
private let interactiveRefreshResetSeconds: TimeInterval = 120

struct CachedPayload {
let payload: MenubarPayload
Expand Down Expand Up @@ -51,6 +52,7 @@ final class AppStore {
private var cache: [PayloadCacheKey: CachedPayload] = [:]
private var cacheDate: String = ""
private var switchTask: Task<Void, Never>?
private var payloadRefreshGeneration: UInt64 = 0
/// Tracks the last successful fetch timestamp per key for stuck-loading
/// diagnostics. NOT used for cache-freshness logic — `CachedPayload.fetchedAt`
/// is authoritative there. This map persists across cache wipes (day
Expand Down Expand Up @@ -87,6 +89,44 @@ final class AppStore {
cache[currentKey] != nil
}

var hasStaleLoading: Bool {
let now = Date()
return loadingStartedAtByKey.values.contains {
now.timeIntervalSince($0) > loadingWatchdogSeconds
}
}

var hasStaleInteractivePayload: Bool {
staleInteractivePayloadAgeSeconds != nil
}

var shouldResetInteractiveRefreshPipeline: Bool {
hasStaleLoading || hasStaleInteractivePayload
}

var staleInteractivePayloadAgeSeconds: Int? {
let keys = Set([
currentKey,
PayloadCacheKey(period: .today, provider: .all),
PayloadCacheKey(period: selectedPeriod, provider: .all),
])
let staleAges = keys.compactMap { key -> TimeInterval? in
guard let cached = cache[key] else { return nil }
let age = Date().timeIntervalSince(cached.fetchedAt)
return age > interactiveRefreshResetSeconds ? age : nil
}
return staleAges.max().map(Int.init)
}

var needsInteractivePayloadRefresh: Bool {
let todayKey = PayloadCacheKey(period: .today, provider: .all)
let periodAllKey = PayloadCacheKey(period: selectedPeriod, provider: .all)
return cache[currentKey]?.isFresh != true ||
cache[todayKey]?.isFresh != true ||
cache[periodAllKey]?.isFresh != true ||
hasStaleLoading
}

/// True if any cached payload reports at least one provider. Used to keep the
/// AgentTabStrip visible across period/provider switches even when the current
/// key's payload is briefly empty (e.g. immediately after a `switchTo` and
Expand All @@ -95,6 +135,12 @@ final class AppStore {
cache.values.contains { !$0.payload.current.providers.isEmpty }
}

#if DEBUG
func setCachedPayloadForTesting(_ payload: MenubarPayload, period: Period, provider: ProviderFilter, fetchedAt: Date) {
cache[PayloadCacheKey(period: period, provider: provider)] = CachedPayload(payload: payload, fetchedAt: fetchedAt)
}
#endif

var findingsCount: Int {
payload.optimize.findingCount
}
Expand Down Expand Up @@ -135,6 +181,7 @@ final class AppStore {
private var inFlightKeys: Set<PayloadCacheKey> = []

func resetLoadingState() {
payloadRefreshGeneration &+= 1
loadingCountsByKey.removeAll()
loadingStartedAtByKey.removeAll()
inFlightKeys.removeAll()
Expand All @@ -161,6 +208,7 @@ final class AppStore {
}
guard !staleEntries.isEmpty else { return false }

payloadRefreshGeneration &+= 1
for (key, started) in staleEntries {
NSLog("CodeBurn: loading stuck for %ds on %@/%@ — auto-clearing",
Int(now.timeIntervalSince(started)), key.period.rawValue, key.provider.rawValue)
Expand Down Expand Up @@ -196,8 +244,15 @@ final class AppStore {
formatter.dateFormat = "yyyy-MM-dd"
let today = formatter.string(from: Date())
if cacheDate != today {
payloadRefreshGeneration &+= 1
cache.removeAll()
loadingCountsByKey.removeAll()
loadingStartedAtByKey.removeAll()
inFlightKeys.removeAll()
attemptedKeys.removeAll()
lastErrorByKey.removeAll()
cacheDate = today
NSLog("CodeBurn: reset menubar payload cache for new day %@", today)
}
}

Expand All @@ -209,6 +264,7 @@ final class AppStore {
invalidateStaleDayCache()
let key = currentKey
let cacheDateAtStart = cacheDate
let generationAtStart = payloadRefreshGeneration
if !force, cache[key]?.isFresh == true { return }
if !force, inFlightKeys.contains(key) { return }
inFlightKeys.insert(key)
Expand Down Expand Up @@ -237,6 +293,10 @@ final class AppStore {
}
do {
let fresh = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: includeOptimize)
if generationAtStart != payloadRefreshGeneration {
NSLog("CodeBurn: dropping fetch result for \(key.period.rawValue)/\(key.provider.rawValue) — refresh pipeline reset mid-fetch")
return
}
if Task.isCancelled {
// Distinguish cancellation (user switched tabs mid-fetch) from
// the silent-no-result path. Without this log, a cancelled
Expand All @@ -263,6 +323,7 @@ final class AppStore {
do {
let fallback = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: false)
guard !Task.isCancelled else { return }
if generationAtStart != payloadRefreshGeneration { return }
if cacheDate != cacheDateAtStart { return }
cache[key] = CachedPayload(payload: fallback, fetchedAt: Date())
lastSuccessByKey[key] = Date()
Expand All @@ -288,8 +349,13 @@ final class AppStore {
func refreshQuietly(period: Period) async {
invalidateStaleDayCache()
let cacheDateAtStart = cacheDate
let generationAtStart = payloadRefreshGeneration
do {
let fresh = try await DataClient.fetch(period: period, provider: .all, includeOptimize: false)
if generationAtStart != payloadRefreshGeneration {
NSLog("CodeBurn: dropping quiet fetch result for \(period.rawValue) — refresh pipeline reset mid-fetch")
return
}
// Same day-rollover guard as refresh(): drop yesterday's payload if
// the calendar rolled over during the fetch.
if cacheDate != cacheDateAtStart { return }
Expand Down
43 changes: 43 additions & 0 deletions mac/Sources/CodeBurnMenubar/AppVersion.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Foundation

enum AppVersion {
static var bundleShortVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
}

static var bundleBuildVersion: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
}

static var normalizedBundleShortVersion: String {
normalize(bundleShortVersion)
}

static var normalizedBundleBuildVersion: String {
normalize(bundleBuildVersion)
}

static var displayBundleShortVersion: String {
display(bundleShortVersion)
}

static func normalize(_ version: String) -> String {
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.lowercased().hasPrefix("mac-v") {
return String(trimmed.dropFirst(5))
}
if trimmed.lowercased().hasPrefix("v") {
return String(trimmed.dropFirst())
}
return trimmed
}

static func display(_ version: String) -> String {
let normalized = normalize(version)
guard !normalized.isEmpty else { return "v?" }
if normalized == "?" || normalized == "dev" || normalized == "dev-preview" || normalized == "—" {
return normalized
}
return "v\(normalized)"
}
}
Loading
Loading