diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ac8459..8597e6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to CodexBuddyMac are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [1.1.2] — 2026-07-14 + +### Fixed + +- **Weekly-only Codex usage.** Codex plans that no longer return a five-hour + window now show their live weekly utilization. The unavailable 5H bar is + hidden in the menu and widget instead of being treated as zero usage. + +- **Reliable local release packaging.** The release script clears Finder + metadata before signing and after archiving, so both the local `.app` and + the extracted ZIP pass macOS code-sign verification. + ## [1.1.1] — 2026-06-26 ### Fixed diff --git a/CodexBuddyMac/CodexBuddyMac.xcodeproj/project.pbxproj b/CodexBuddyMac/CodexBuddyMac.xcodeproj/project.pbxproj index 7cebaca..2467169 100644 --- a/CodexBuddyMac/CodexBuddyMac.xcodeproj/project.pbxproj +++ b/CodexBuddyMac/CodexBuddyMac.xcodeproj/project.pbxproj @@ -260,7 +260,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; @@ -270,7 +270,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.1.1; + MARKETING_VERSION = 1.1.2; PRODUCT_BUNDLE_IDENTIFIER = com.example.CodexBuddyMac; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -290,7 +290,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; @@ -300,7 +300,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.1.1; + MARKETING_VERSION = 1.1.2; PRODUCT_BUNDLE_IDENTIFIER = com.example.CodexBuddyMac; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/CodexBuddyMac/CodexBuddyMac/AppDelegate.swift b/CodexBuddyMac/CodexBuddyMac/AppDelegate.swift index 31200c3..8f66ac5 100644 --- a/CodexBuddyMac/CodexBuddyMac/AppDelegate.swift +++ b/CodexBuddyMac/CodexBuddyMac/AppDelegate.swift @@ -78,7 +78,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { button.image = NSImage(systemSymbolName: poller.menuBarBatterySymbol, accessibilityDescription: nil) button.title = " \(poller.menuBarStatusText)" - button.toolTip = "CodexBuddy 5-hour usage" + button.toolTip = "CodexBuddy usage" } @objc private func togglePopover() { diff --git a/CodexBuddyMac/CodexBuddyMac/Bridge/codex_usage_server.py b/CodexBuddyMac/CodexBuddyMac/Bridge/codex_usage_server.py index 2283065..b1c0f03 100755 --- a/CodexBuddyMac/CodexBuddyMac/Bridge/codex_usage_server.py +++ b/CodexBuddyMac/CodexBuddyMac/Bridge/codex_usage_server.py @@ -299,6 +299,49 @@ def convert_window(window: dict[str, Any] | None) -> dict[str, Any]: } +def is_five_hour_window(window: dict[str, Any] | None) -> bool: + """Return whether an upstream Codex window is the legacy short window. + + Codex now returns a seven-day primary window for some plans, with no + secondary window. Older responses used a five-hour primary window and a + weekly secondary window. ``limit_window_seconds`` lets us distinguish + those two shapes instead of always calling the primary window "5H". + """ + if not isinstance(window, dict): + return False + try: + seconds = float(window.get("limit_window_seconds")) + except (TypeError, ValueError): + # Preserve the established mapping for older responses which did not + # provide a duration. + return True + return 0 < seconds <= 6 * 60 * 60 + + +def codex_windows_from_rate_limit(rate_limit: dict[str, Any] | None) -> dict[str, dict[str, Any]]: + """Normalize Codex rate limits to the app's short-window/weekly schema. + + A plan with only a seven-day primary window has no 5H limit. Representing + that missing window as unavailable lets the Swift UI hide it while still + displaying the live weekly utilization. + """ + limits = rate_limit if isinstance(rate_limit, dict) else {} + primary = limits.get("primary_window") + secondary = limits.get("secondary_window") + + if is_five_hour_window(primary): + five_hour = primary + weekly = secondary + else: + five_hour = None + weekly = primary if isinstance(primary, dict) else secondary + + return { + "five_hour": convert_window(five_hour), + "weekly": convert_window(weekly), + } + + def convert_percent_window(window: dict[str, Any] | None, reset_key: str = "reset_at") -> dict[str, Any]: if not isinstance(window, dict): return {"used": -1, "limit": 100, "remaining": -1, "reset_at": ""} @@ -919,19 +962,14 @@ def fetch_codex_usage(auth_file: Path) -> dict[str, Any]: upstream = json.loads(raw.decode("utf-8")) rate_limit = upstream.get("rate_limit") or {} - primary = rate_limit.get("primary_window") - secondary = rate_limit.get("secondary_window") now = datetime.now(timezone.utc).isoformat() return { "plan": str(upstream.get("plan_type", "unknown")).title(), "updated_at": now, - "codex": { - "five_hour": convert_window(primary), - "weekly": convert_window(secondary), - }, + "codex": codex_windows_from_rate_limit(rate_limit), "source": CODEX_USAGE_URL, - "note": "Live Codex OAuth usage. Values are percentages because OpenAI returns used_percent.", + "note": "Live Codex OAuth usage. The bridge labels the reported window from its upstream duration.", } diff --git a/CodexBuddyMac/CodexBuddyMac/Bridge/usage.json b/CodexBuddyMac/CodexBuddyMac/Bridge/usage.json index 06ce15f..f255dec 100644 --- a/CodexBuddyMac/CodexBuddyMac/Bridge/usage.json +++ b/CodexBuddyMac/CodexBuddyMac/Bridge/usage.json @@ -3,9 +3,9 @@ "updated_at": "1970-01-01T00:00:00+00:00", "codex": { "five_hour": { - "used": 0, + "used": -1, "limit": 100, - "remaining": 100, + "remaining": -1, "reset_at": "" }, "weekly": { diff --git a/CodexBuddyMac/CodexBuddyMac/CodexBuddyUsage.swift b/CodexBuddyMac/CodexBuddyMac/CodexBuddyUsage.swift index 0cc40cd..c526a90 100644 --- a/CodexBuddyMac/CodexBuddyMac/CodexBuddyUsage.swift +++ b/CodexBuddyMac/CodexBuddyMac/CodexBuddyUsage.swift @@ -61,14 +61,30 @@ struct UsageWindow: Codable, Equatable { guard let resetAt, !resetAt.isEmpty else { return nil } return UsageDateParser.date(from: resetAt) } + + var isAvailable: Bool { + used >= 0 && remaining >= 0 && limit > 0 + } } extension ServiceUsage { + var primaryWindow: UsageWindow { + fiveHour.isAvailable ? fiveHour : weekly + } + + var primaryWindowLabel: String { + fiveHour.isAvailable ? "5H" : "Week" + } + + var hasAvailableWindow: Bool { + fiveHour.isAvailable || weekly.isAvailable + } + var nextResetText: String? { let now = Date() let candidates: [(String, Date)] = [ - ("5H", fiveHour.resetDate), - ("Week", weekly.resetDate) + ("5H", fiveHour.isAvailable ? fiveHour.resetDate : nil), + ("Week", weekly.isAvailable ? weekly.resetDate : nil) ].compactMap { label, date in guard let date, date > now else { return nil } return (label, date) diff --git a/CodexBuddyMac/CodexBuddyMac/MenuBarView.swift b/CodexBuddyMac/CodexBuddyMac/MenuBarView.swift index 0cf2ba3..b31659f 100644 --- a/CodexBuddyMac/CodexBuddyMac/MenuBarView.swift +++ b/CodexBuddyMac/CodexBuddyMac/MenuBarView.swift @@ -127,7 +127,7 @@ struct ServiceDetailView: View { Spacer() - Text("\(usage.fiveHour.remaining) left") + Text(usage.hasAvailableWindow ? "\(usage.primaryWindow.remaining) left" : "Unavailable") .font(.caption2) .foregroundStyle(.secondary) } @@ -144,8 +144,17 @@ struct ServiceDetailView: View { .foregroundStyle(.orange) } - UsageBar(label: "5H", window: usage.fiveHour, tint: tint) - UsageBar(label: "Week", window: usage.weekly, tint: tint.opacity(0.75)) + if usage.fiveHour.isAvailable { + UsageBar(label: "5H", window: usage.fiveHour, tint: tint) + } + if usage.weekly.isAvailable { + UsageBar(label: "Week", window: usage.weekly, tint: tint.opacity(0.75)) + } + if !usage.hasAvailableWindow { + Text("Usage unavailable") + .font(.caption2) + .foregroundStyle(.secondary) + } } .padding(.horizontal, 14) .padding(.vertical, 10) @@ -248,15 +257,15 @@ struct MenuActionRow: View { extension UsagePoller { var menuBarStatusText: String { - "\(AppSettings.codexLabel.prefix(2)) \(usage.codex.fiveHour.usedPercent)% \(AppSettings.claudeLabel.prefix(2)) \(usage.claude.fiveHour.usedPercent)%" + "\(AppSettings.codexLabel.prefix(2)) \(usage.codex.primaryWindow.usedPercent)% \(AppSettings.claudeLabel.prefix(2)) \(usage.claude.primaryWindow.usedPercent)%" } var menuBarBatterySymbol: String { guard isOnline else { return "exclamationmark.circle" } let combinedUsage = max( - usage.codex.fiveHour.usedPercent, - usage.claude.fiveHour.usedPercent + usage.codex.primaryWindow.usedPercent, + usage.claude.primaryWindow.usedPercent ) switch combinedUsage { case 0..<25: diff --git a/CodexBuddyMac/CodexBuddyWidgetExtension/CodexBuddyUsage.swift b/CodexBuddyMac/CodexBuddyWidgetExtension/CodexBuddyUsage.swift index 996ff46..25a1637 100644 --- a/CodexBuddyMac/CodexBuddyWidgetExtension/CodexBuddyUsage.swift +++ b/CodexBuddyMac/CodexBuddyWidgetExtension/CodexBuddyUsage.swift @@ -51,6 +51,24 @@ struct UsageWindow: Codable, Equatable { guard limit > 0 else { return 0 } return max(0, min(100, Int((Double(remaining) / Double(limit)) * 100.0))) } + + var isAvailable: Bool { + used >= 0 && remaining >= 0 && limit > 0 + } +} + +extension ServiceUsage { + var primaryWindow: UsageWindow { + fiveHour.isAvailable ? fiveHour : weekly + } + + var primaryWindowLabel: String { + fiveHour.isAvailable ? "5H" : "WK" + } + + var hasAvailableWindow: Bool { + fiveHour.isAvailable || weekly.isAvailable + } } extension CodexBuddyUsage { diff --git a/CodexBuddyMac/CodexBuddyWidgetExtension/WidgetView.swift b/CodexBuddyMac/CodexBuddyWidgetExtension/WidgetView.swift index 19f3dd1..411f2ce 100644 --- a/CodexBuddyMac/CodexBuddyWidgetExtension/WidgetView.swift +++ b/CodexBuddyMac/CodexBuddyWidgetExtension/WidgetView.swift @@ -47,11 +47,11 @@ struct SmallWidgetView: View { .font(.caption) .fontWeight(.bold) - BigPercent(value: usage.codex.fiveHour.usedPercent, label: "5H") + BigPercent(value: usage.codex.primaryWindow.usedPercent, label: usage.codex.primaryWindowLabel) Divider() - Text("CLAUDE \(usage.claude.fiveHour.usedPercent)%") + Text("CLAUDE \(usage.claude.primaryWindow.usedPercent)%") .font(.caption2) .foregroundStyle(.secondary) } @@ -69,8 +69,17 @@ struct WidgetServiceView: View { .font(.caption) .fontWeight(.bold) - WidgetUsageRow(label: "5H", window: usage.fiveHour) - WidgetUsageRow(label: "WK", window: usage.weekly) + if usage.fiveHour.isAvailable { + WidgetUsageRow(label: "5H", window: usage.fiveHour) + } + if usage.weekly.isAvailable { + WidgetUsageRow(label: "WK", window: usage.weekly) + } + if !usage.hasAvailableWindow { + Text("Unavailable") + .font(.caption2) + .foregroundStyle(.secondary) + } } .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/README.md b/README.md index ae3dfa1..1838ee0 100644 --- a/README.md +++ b/README.md @@ -7,17 +7,17 @@ It runs a local Python bridge, polls the providers' usage endpoints, and keeps t latest snapshot on the Mac.
-
+