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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions CodexBuddyMac/CodexBuddyMac.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion CodexBuddyMac/CodexBuddyMac/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
52 changes: 45 additions & 7 deletions CodexBuddyMac/CodexBuddyMac/Bridge/codex_usage_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": ""}
Expand Down Expand Up @@ -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.",
}


Expand Down
4 changes: 2 additions & 2 deletions CodexBuddyMac/CodexBuddyMac/Bridge/usage.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
20 changes: 18 additions & 2 deletions CodexBuddyMac/CodexBuddyMac/CodexBuddyUsage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 15 additions & 6 deletions CodexBuddyMac/CodexBuddyMac/MenuBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions CodexBuddyMac/CodexBuddyWidgetExtension/CodexBuddyUsage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 13 additions & 4 deletions CodexBuddyMac/CodexBuddyWidgetExtension/WidgetView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,30 @@ It runs a local Python bridge, polls the providers' usage endpoints, and keeps t
latest snapshot on the Mac.

<p align="center">
<img src="docs/images/codexbuddy-menu.png?v=2" width="336" alt="CodexBuddyMac menu bar popover showing Codex and Claude usage with next reset times">
<img src="docs/images/codexbuddy-menu.png?v=3" width="336" alt="CodexBuddyMac menu bar popover showing Codex weekly usage and Claude usage with reset times">
</p>

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
- Automatic Claude OAuth token refresh (self-healing when the login expires)
- 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,
Expand Down
Binary file modified docs/images/codexbuddy-menu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 9 additions & 1 deletion scripts/build-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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.
Expand All @@ -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"
34 changes: 34 additions & 0 deletions tests/test_claude_auth_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading