Skip to content
Open
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
4 changes: 4 additions & 0 deletions macos-app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.build/
Clawdmeter.app/
*.xcodeproj
.swiftpm/
13 changes: 13 additions & 0 deletions macos-app/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// swift-tools-version:5.9
import PackageDescription

let package = Package(
name: "Clawdmeter",
platforms: [.macOS(.v13)],
targets: [
.executableTarget(
name: "Clawdmeter",
path: "Sources/Clawdmeter"
)
]
)
52 changes: 52 additions & 0 deletions macos-app/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Clawdmeter — app de barra de menú para macOS

Versión nativa (Swift/AppKit) del monitor de uso de Claude Code, como ícono en
la barra de menú. **No usa el dispositivo físico ni BLE**: lee el mismo dato que
el daemon (token OAuth desde el Keychain de macOS + headers
`anthropic-ratelimit-unified-*` de la API de Anthropic).

## Qué muestra

- En la barra de menú: el porcentaje de uso de la ventana de **5 horas** (ej. `46%`).
- En el popover (click en el ícono): dos medidores — **Sesión (5h)** y **Semana (7d)** —
con porcentaje, barra de color (verde / amarillo / rojo) y cuenta regresiva de
reinicio, más el estado y la hora de la última actualización.
- Botones para actualizar al instante y salir.

Hace polling cada 60 s (igual que el daemon).

## Requisitos

- macOS 13+
- Toolchain de Swift (viene con Xcode / Command Line Tools)
- Claude Code con sesión iniciada (`claude login`) — el token vive en el Keychain
bajo el servicio `Claude Code-credentials`.

## Compilar e instalar

```bash
cd macos-app
./build.sh # produce Clawdmeter.app
open Clawdmeter.app # probar
cp -r Clawdmeter.app /Applications/
```

Para que arranque solo: **Ajustes del Sistema → General → Items de inicio → +**
y agregar `Clawdmeter.app`.

## Desarrollo

```bash
swift build -c release
swift run # corre sin empaquetar
```

## Estructura

| Archivo | Rol |
|---|---|
| `Sources/Clawdmeter/Usage.swift` | Lectura del token (Keychain) y polling de la API + parseo de headers |
| `Sources/Clawdmeter/Store.swift` | `ObservableObject` con el loop de polling (60 s) |
| `Sources/Clawdmeter/PopoverView.swift` | UI SwiftUI del popover (medidores) |
| `Sources/Clawdmeter/main.swift` | `AppDelegate`, `NSStatusItem`, popover |
| `build.sh` | Empaqueta el binario en `Clawdmeter.app` (`LSUIElement`) |
154 changes: 154 additions & 0 deletions macos-app/Sources/Clawdmeter/PopoverView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import SwiftUI
import AppKit

/// The content of the menu-bar popover: two usage meters plus controls.
struct PopoverView: View {
@ObservedObject var store: UsageStore

var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("Clawdmeter")
.font(.headline)
Spacer()
if store.isRefreshing {
ProgressView().controlSize(.small)
}
}

if let usage = store.usage {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Surface refresh failures when cached usage exists

After the first successful poll, usage remains non-nil when a later refresh fails because UsageStore.refresh() only sets lastError. This branch therefore continues showing the cached meters instead of the error, while main.swift also suppresses the ! indicator whenever cached usage exists; an expired token or prolonged network outage can consequently leave an arbitrarily stale percentage displayed with no failure indication. Retain the last snapshot if desired, but surface its stale/error state.

Useful? React with 👍 / 👎.

Meter(title: "Sesión (5h)", pct: usage.fiveHourPct,
resetMin: usage.fiveHourResetMin, isBinding: usage.fiveHourIsBinding)
Meter(title: "Semana (7d)", pct: usage.weekPct,
resetMin: usage.weekResetMin, isBinding: usage.weekIsBinding)

if usage.overageRelevant {
Meter(title: "Overage", pct: usage.overagePct,
resetMin: usage.overageResetMin, isBinding: false, isOverage: true)
if usage.overageStatus == "rejected" {
Text(overageReasonText(usage.overageDisabledReason))
.font(.caption2)
.foregroundColor(.orange)
.fixedSize(horizontal: false, vertical: true)
}
}

HStack(spacing: 6) {
Circle()
.fill(statusColor(usage.overallStatus))
.frame(width: 7, height: 7)
Text("Estado: \(usage.overallStatus)")
.font(.caption)
.foregroundColor(.secondary)
}
} else if let err = store.lastError {
Label(err, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundColor(.orange)
.fixedSize(horizontal: false, vertical: true)
} else {
HStack { ProgressView().controlSize(.small); Text("Cargando…").foregroundColor(.secondary) }
}

Divider()

HStack {
Text(updatedLabel)
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
Button {
Task { await store.refresh() }
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
.help("Actualizar ahora")

Button("Salir") { NSApp.terminate(nil) }
.buttonStyle(.borderless)
}
}
.padding(16)
.frame(width: 260)
}

private var updatedLabel: String {
guard let d = store.lastUpdated else { return "Sin datos aún" }
let f = DateFormatter()
f.dateFormat = "HH:mm:ss"
return "Actualizado \(f.string(from: d))"
}

private func statusColor(_ status: String) -> Color {
switch status {
case "allowed", "unknown": return .green
case "rejected", "blocked": return .red
default: return .orange // p.ej. "warning"
}
}

private func overageReasonText(_ reason: String) -> String {
switch reason {
case "org_spend_cap_reached": return "Overage bloqueado: tope de gasto de la organización alcanzado."
case "": return "Overage bloqueado."
default: return "Overage bloqueado: \(reason)."
}
}
}

/// A labeled progress bar with percentage and reset countdown.
private struct Meter: View {
let title: String
let pct: Int
let resetMin: Int
var isBinding: Bool = false
var isOverage: Bool = false

var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text(title).font(.caption).foregroundColor(.secondary)
if isBinding {
Text("límite actual")
.font(.system(size: 9, weight: .semibold))
.padding(.horizontal, 5).padding(.vertical, 1)
.background(Color.accentColor.opacity(0.18))
.foregroundColor(.accentColor)
.clipShape(Capsule())
}
Spacer()
Text("\(pct)%").font(.system(.caption, design: .monospaced)).bold()
}
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.secondary.opacity(0.2))
RoundedRectangle(cornerRadius: 4)
.fill(barColor)
.frame(width: geo.size.width * CGFloat(min(pct, 100)) / 100.0)
}
}
.frame(height: 8)
Text(resetLabel)
.font(.caption2)
.foregroundColor(.secondary)
}
}

private var barColor: Color {
if isOverage { return pct >= 100 ? .red : .purple }
switch pct {
case ..<60: return .green
case ..<85: return .yellow
default: return .red
}
}

private var resetLabel: String {
if resetMin <= 0 { return "se reinicia pronto" }
if resetMin < 60 { return "se reinicia en \(resetMin) min" }
let h = resetMin / 60, m = resetMin % 60
return m == 0 ? "se reinicia en \(h) h" : "se reinicia en \(h) h \(m) min"
}
}
37 changes: 37 additions & 0 deletions macos-app/Sources/Clawdmeter/Store.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation
import Combine

/// Owns the polling loop and publishes the latest snapshot to the UI.
@MainActor
final class UsageStore: ObservableObject {
@Published private(set) var usage: Usage?
@Published private(set) var lastError: String?
@Published private(set) var lastUpdated: Date?
@Published private(set) var isRefreshing = false

/// Seconds between automatic polls — matches the daemon's POLL_INTERVAL.
let pollInterval: TimeInterval = 60

private var timer: Timer?

func start() {
Task { await refresh() }
timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
Task { await self?.refresh() }
}
}

func refresh() async {
if isRefreshing { return }
isRefreshing = true
defer { isRefreshing = false }
do {
let fresh = try await UsagePoller.fetch()
usage = fresh
lastError = nil
lastUpdated = Date()
} catch {
lastError = (error as? UsageError)?.description ?? error.localizedDescription
}
}
}
Loading