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.

- CodexBuddyMac menu bar popover showing Codex and Claude usage with next reset times + CodexBuddyMac menu bar popover showing Codex weekly usage and Claude usage with reset times

See both services at a glance without interrupting your workflow. The compact -menu shows current five-hour and weekly usage, remaining capacity, connection -status, and one-click refresh controls. +menu shows each provider's currently available usage windows, remaining +capacity, connection status, and one-click refresh controls. ## Highlights - Codex and Claude Code usage in one menu bar view -- Five-hour and weekly usage windows +- Provider usage windows, including weekly-only Codex plans - Next provider usage reset time and combined capacity battery indicator - Local-only bridge bound to `127.0.0.1` - Automatic refresh with offline fallback @@ -25,6 +25,12 @@ status, and one-click refresh controls. - Settings panel with bridge controls, diagnostics, and a Claude login refresh - Optional launch at login +## Latest Release + +Version 1.1.2 detects Codex plans that provide a weekly-only usage window. It +shows that live weekly usage without a misleading five-hour bar, while Claude +continues to show its available five-hour and weekly windows. + See [CHANGELOG.md](CHANGELOG.md) for recent fixes and changes. Possible longer-term improvements, including a native Swift 2.0 architecture, diff --git a/docs/images/codexbuddy-menu.png b/docs/images/codexbuddy-menu.png index ea91049..5876233 100644 Binary files a/docs/images/codexbuddy-menu.png and b/docs/images/codexbuddy-menu.png differ diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 61606cb..89cea63 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -7,7 +7,7 @@ developer_dir="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" derived_data="${DERIVED_DATA_DIR:-$PWD/build/ReleaseDerivedData}" release_dir="$PWD/Dist/Release" app_name="CodexBuddyMac.app" -archive_name="CodexBuddyMac-1.1.1.zip" +archive_name="CodexBuddyMac-1.1.2.zip" DEVELOPER_DIR="$developer_dir" xcodebuild \ -project CodexBuddyMac/CodexBuddyMac.xcodeproj \ @@ -22,6 +22,9 @@ mkdir -p "$release_dir" rm -rf "$release_dir/$app_name" "$release_dir/$archive_name" ditto "$derived_data/Build/Products/Release/$app_name" "$release_dir/$app_name" xattr -cr "$release_dir/$app_name" +# Finder metadata can survive a local copy even after the generic clear above. +# codesign rejects it, so remove it explicitly before signing the release app. +xattr -d com.apple.FinderInfo "$release_dir/$app_name" 2>/dev/null || true # Ad-hoc signing makes the local test bundle internally verifiable. A public # release still requires a Developer ID Application certificate and notarization. @@ -39,6 +42,11 @@ codesign --verify --deep --strict --verbose=2 "$verify_dir/$app_name" rm -rf "$verify_dir" trap - EXIT +# Archive creation can restore Finder metadata on the source bundle. Remove it +# once more so both the ZIP and the adjacent .app are valid release artifacts. +xattr -d com.apple.FinderInfo "$release_dir/$app_name" 2>/dev/null || true +codesign --verify --deep --strict --verbose=2 "$release_dir/$app_name" + printf 'Release candidate: %s\n' "$release_dir/$app_name" printf 'Archive: %s\n' "$release_dir/$archive_name" cat "$release_dir/SHA256SUMS.txt" diff --git a/tests/test_claude_auth_recovery.py b/tests/test_claude_auth_recovery.py index a1e17bf..e23a71f 100644 --- a/tests/test_claude_auth_recovery.py +++ b/tests/test_claude_auth_recovery.py @@ -29,6 +29,40 @@ def load_bridge(name: str): class ClaudeAuthRecoveryTests(unittest.TestCase): + def test_codex_weekly_primary_window_is_not_shown_as_five_hour(self): + bridge = load_bridge("bridge_codex_weekly_only_test") + + windows = bridge.codex_windows_from_rate_limit({ + "primary_window": { + "used_percent": 23, + "limit_window_seconds": 7 * 24 * 60 * 60, + "reset_at": 1_784_621_804, + }, + "secondary_window": None, + }) + + self.assertEqual(windows["five_hour"]["used"], -1) + self.assertEqual(windows["weekly"]["used"], 23) + + def test_codex_legacy_short_primary_window_is_preserved(self): + bridge = load_bridge("bridge_codex_two_window_test") + + windows = bridge.codex_windows_from_rate_limit({ + "primary_window": { + "used_percent": 12, + "limit_window_seconds": 5 * 60 * 60, + "reset_at": 1_784_621_804, + }, + "secondary_window": { + "used_percent": 34, + "limit_window_seconds": 7 * 24 * 60 * 60, + "reset_at": 1_784_621_804, + }, + }) + + self.assertEqual(windows["five_hour"]["used"], 12) + self.assertEqual(windows["weekly"]["used"], 34) + def test_uses_current_claude_cli_refresh_endpoint(self): bridge = load_bridge("bridge_endpoint_test") self.assertEqual(