Skip to content

Commit bc92b49

Browse files
committed
feat(mac): auto-update checker and Plan pane button cleanup
Remove the broken "Connect Claude" / "Reconnect Claude" buttons from the Plan pane -- they opened a terminal session that did nothing useful for already-logged-in users. Keep only the "Retry" button. Add an auto-update checker that queries GitHub releases every 2 days in the background. When a newer menubar build is available, an "Update" pill appears in the header. Clicking it runs the existing installer flow (download, replace, relaunch) with no manual steps.
1 parent 72ccf34 commit bc92b49

4 files changed

Lines changed: 153 additions & 59 deletions

File tree

mac/Sources/CodeBurnMenubar/CodeBurnApp.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
2828
private var statusItem: NSStatusItem!
2929
private var popover: NSPopover!
3030
private let store = AppStore()
31+
let updateChecker = UpdateChecker()
3132
private var refreshTask: Task<Void, Never>?
3233

3334
func applicationDidFinishLaunching(_ notification: Notification) {
@@ -39,8 +40,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
3940
setupPopover()
4041
observeStore()
4142
startRefreshLoop()
42-
// Subscription is fetched lazily when the user opens the Plan pill, so the macOS
43-
// Keychain prompt never fires until the user explicitly asks for it.
43+
Task { await updateChecker.checkIfNeeded() }
4444
}
4545

4646
/// Loads the currency code persisted by `codeburn currency` so a relaunch picks up where
@@ -161,6 +161,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
161161

162162
let content = MenuBarContent()
163163
.environment(store)
164+
.environment(updateChecker)
164165
.frame(width: popoverWidth)
165166

166167
popover.contentViewController = NSHostingController(rootView: content)
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import Foundation
2+
import Observation
3+
4+
private let releasesAPI = "https://api.github.com/repos/getagentseal/codeburn/releases/latest"
5+
private let checkIntervalSeconds: TimeInterval = 2 * 24 * 60 * 60
6+
private let lastCheckKey = "UpdateChecker.lastCheckDate"
7+
private let cachedVersionKey = "UpdateChecker.latestVersion"
8+
9+
@MainActor
10+
@Observable
11+
final class UpdateChecker {
12+
var latestVersion: String?
13+
var isUpdating = false
14+
var updateError: String?
15+
16+
var updateAvailable: Bool {
17+
guard let latest = latestVersion else { return false }
18+
let current = currentVersion
19+
return !current.isEmpty && current != "dev" && latest != current
20+
}
21+
22+
var currentVersion: String {
23+
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
24+
}
25+
26+
func checkIfNeeded() async {
27+
let lastCheck = UserDefaults.standard.double(forKey: lastCheckKey)
28+
let now = Date().timeIntervalSince1970
29+
if now - lastCheck < checkIntervalSeconds {
30+
latestVersion = UserDefaults.standard.string(forKey: cachedVersionKey)
31+
return
32+
}
33+
await check()
34+
}
35+
36+
func check() async {
37+
guard let url = URL(string: releasesAPI) else { return }
38+
var request = URLRequest(url: url)
39+
request.setValue("codeburn-menubar-updater", forHTTPHeaderField: "User-Agent")
40+
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
41+
42+
do {
43+
let (data, _) = try await URLSession.shared.data(for: request)
44+
let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
45+
guard let asset = release.assets.first(where: {
46+
$0.name.hasPrefix("CodeBurnMenubar-") && $0.name.hasSuffix(".zip")
47+
}) else { return }
48+
49+
let version = asset.name
50+
.replacingOccurrences(of: "CodeBurnMenubar-", with: "")
51+
.replacingOccurrences(of: ".zip", with: "")
52+
53+
latestVersion = version
54+
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastCheckKey)
55+
UserDefaults.standard.set(version, forKey: cachedVersionKey)
56+
} catch {
57+
NSLog("CodeBurn: update check failed: \(error)")
58+
}
59+
}
60+
61+
func performUpdate() {
62+
isUpdating = true
63+
updateError = nil
64+
65+
let process = CodeburnCLI.makeProcess(subcommand: ["menubar", "--force"])
66+
process.standardOutput = FileHandle.nullDevice
67+
process.standardError = FileHandle.nullDevice
68+
69+
do {
70+
try process.run()
71+
} catch {
72+
isUpdating = false
73+
updateError = error.localizedDescription
74+
NSLog("CodeBurn: update spawn failed: \(error)")
75+
}
76+
}
77+
}
78+
79+
private struct GitHubRelease: Decodable {
80+
let tag_name: String
81+
let assets: [GitHubAsset]
82+
}
83+
84+
private struct GitHubAsset: Decodable {
85+
let name: String
86+
let browser_download_url: String
87+
}

mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift

Lines changed: 16 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,7 +1041,6 @@ private struct PlanLoadingView: View {
10411041

10421042
private struct PlanNoCredentialsView: View {
10431043
@Environment(AppStore.self) private var store
1044-
@State private var showManualFallback = false
10451044

10461045
var body: some View {
10471046
VStack(spacing: 8) {
@@ -1051,32 +1050,17 @@ private struct PlanNoCredentialsView: View {
10511050
Text("No Claude subscription connected")
10521051
.font(.system(size: 12, weight: .semibold))
10531052
.foregroundStyle(.primary)
1054-
if showManualFallback {
1055-
Text("Terminal.app isn't available. Open your terminal and run `claude login`, then click Retry.")
1056-
.font(.system(size: 10.5))
1057-
.foregroundStyle(.secondary)
1058-
.multilineTextAlignment(.center)
1059-
.frame(maxWidth: 280)
1060-
} else {
1061-
Text("Click Connect to sign in with Claude, then return here.")
1062-
.font(.system(size: 10.5))
1063-
.foregroundStyle(.secondary)
1064-
.multilineTextAlignment(.center)
1065-
.frame(maxWidth: 260)
1066-
}
1067-
HStack(spacing: 8) {
1068-
Button("Connect Claude") {
1069-
if !TerminalLauncher.openClaudeLogin() { showManualFallback = true }
1070-
}
1071-
.controlSize(.small)
1072-
.buttonStyle(.borderedProminent)
1073-
.tint(Theme.brandAccent)
1074-
Button("Retry") {
1075-
Task { await store.refreshSubscription() }
1076-
}
1077-
.controlSize(.small)
1078-
.buttonStyle(.bordered)
1053+
Text("Sign in with Claude Code, then click Retry.")
1054+
.font(.system(size: 10.5))
1055+
.foregroundStyle(.secondary)
1056+
.multilineTextAlignment(.center)
1057+
.frame(maxWidth: 260)
1058+
Button("Retry") {
1059+
Task { await store.refreshSubscription() }
10791060
}
1061+
.controlSize(.small)
1062+
.buttonStyle(.borderedProminent)
1063+
.tint(Theme.brandAccent)
10801064
}
10811065
.frame(maxWidth: .infinity)
10821066
.padding(.vertical, 14)
@@ -1086,7 +1070,6 @@ private struct PlanNoCredentialsView: View {
10861070
private struct PlanFailedView: View {
10871071
@Environment(AppStore.self) private var store
10881072
let error: String?
1089-
@State private var showManualFallback = false
10901073

10911074
var body: some View {
10921075
VStack(spacing: 8) {
@@ -1096,33 +1079,20 @@ private struct PlanFailedView: View {
10961079
Text("Couldn't load plan data")
10971080
.font(.system(size: 12, weight: .semibold))
10981081
.foregroundStyle(.primary)
1099-
if showManualFallback {
1100-
Text("Terminal.app isn't available. Open your terminal and run `claude login`, then click Retry.")
1101-
.font(.system(size: 10.5))
1102-
.foregroundStyle(.secondary)
1103-
.multilineTextAlignment(.center)
1104-
.frame(maxWidth: 280)
1105-
} else if let error {
1082+
if let error {
11061083
Text(error)
11071084
.font(.system(size: 10))
11081085
.foregroundStyle(.tertiary)
11091086
.multilineTextAlignment(.center)
11101087
.frame(maxWidth: 280)
11111088
.lineLimit(3)
11121089
}
1113-
HStack(spacing: 8) {
1114-
Button("Reconnect Claude") {
1115-
if !TerminalLauncher.openClaudeLogin() { showManualFallback = true }
1116-
}
1117-
.controlSize(.small)
1118-
.buttonStyle(.borderedProminent)
1119-
.tint(Theme.brandAccent)
1120-
Button("Retry") {
1121-
Task { await store.refreshSubscription() }
1122-
}
1123-
.controlSize(.small)
1124-
.buttonStyle(.bordered)
1090+
Button("Retry") {
1091+
Task { await store.refreshSubscription() }
11251092
}
1093+
.controlSize(.small)
1094+
.buttonStyle(.borderedProminent)
1095+
.tint(Theme.brandAccent)
11261096
}
11271097
.frame(maxWidth: .infinity)
11281098
.padding(.vertical, 14)

mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -183,25 +183,61 @@ private struct BurnFlame: View {
183183
}
184184

185185
private struct Header: View {
186+
@Environment(UpdateChecker.self) private var updateChecker
187+
186188
var body: some View {
187-
VStack(alignment: .leading, spacing: 1) {
188-
(
189-
Text("Code").foregroundStyle(.primary)
190-
+ Text("Burn").foregroundStyle(Theme.brandAccent)
191-
)
192-
.font(.system(size: 13, weight: .semibold))
193-
.tracking(-0.15)
194-
Text("AI Coding Cost Tracker")
195-
.font(.system(size: 10.5))
196-
.foregroundStyle(.secondary)
189+
HStack {
190+
VStack(alignment: .leading, spacing: 1) {
191+
(
192+
Text("Code").foregroundStyle(.primary)
193+
+ Text("Burn").foregroundStyle(Theme.brandAccent)
194+
)
195+
.font(.system(size: 13, weight: .semibold))
196+
.tracking(-0.15)
197+
Text("AI Coding Cost Tracker")
198+
.font(.system(size: 10.5))
199+
.foregroundStyle(.secondary)
200+
}
201+
Spacer()
202+
if updateChecker.updateAvailable {
203+
UpdateBadge()
204+
}
197205
}
198-
.frame(maxWidth: .infinity, alignment: .leading)
199206
.padding(.horizontal, 14)
200207
.padding(.top, 10)
201208
.padding(.bottom, 8)
202209
}
203210
}
204211

212+
private struct UpdateBadge: View {
213+
@Environment(UpdateChecker.self) private var updateChecker
214+
215+
var body: some View {
216+
Button {
217+
updateChecker.performUpdate()
218+
} label: {
219+
HStack(spacing: 4) {
220+
if updateChecker.isUpdating {
221+
ProgressView()
222+
.controlSize(.mini)
223+
.scaleEffect(0.7)
224+
} else {
225+
Image(systemName: "arrow.down.circle.fill")
226+
.font(.system(size: 10))
227+
}
228+
Text(updateChecker.isUpdating ? "Updating..." : "Update")
229+
.font(.system(size: 10, weight: .medium))
230+
}
231+
.padding(.horizontal, 8)
232+
.padding(.vertical, 4)
233+
}
234+
.buttonStyle(.borderedProminent)
235+
.tint(Theme.brandAccent)
236+
.controlSize(.mini)
237+
.disabled(updateChecker.isUpdating)
238+
}
239+
}
240+
205241
struct FlameMark: View {
206242
var body: some View {
207243
ZStack {

0 commit comments

Comments
 (0)