diff --git a/macos-app/.gitignore b/macos-app/.gitignore new file mode 100644 index 0000000..98178a9 --- /dev/null +++ b/macos-app/.gitignore @@ -0,0 +1,4 @@ +.build/ +Clawdmeter.app/ +*.xcodeproj +.swiftpm/ diff --git a/macos-app/Package.swift b/macos-app/Package.swift new file mode 100644 index 0000000..7f9e9ee --- /dev/null +++ b/macos-app/Package.swift @@ -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" + ) + ] +) diff --git a/macos-app/README.md b/macos-app/README.md new file mode 100644 index 0000000..9de583f --- /dev/null +++ b/macos-app/README.md @@ -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`) | diff --git a/macos-app/Sources/Clawdmeter/PopoverView.swift b/macos-app/Sources/Clawdmeter/PopoverView.swift new file mode 100644 index 0000000..df27a69 --- /dev/null +++ b/macos-app/Sources/Clawdmeter/PopoverView.swift @@ -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 { + 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" + } +} diff --git a/macos-app/Sources/Clawdmeter/Store.swift b/macos-app/Sources/Clawdmeter/Store.swift new file mode 100644 index 0000000..c8eca0e --- /dev/null +++ b/macos-app/Sources/Clawdmeter/Store.swift @@ -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 + } + } +} diff --git a/macos-app/Sources/Clawdmeter/Usage.swift b/macos-app/Sources/Clawdmeter/Usage.swift new file mode 100644 index 0000000..a8e922c --- /dev/null +++ b/macos-app/Sources/Clawdmeter/Usage.swift @@ -0,0 +1,182 @@ +import Foundation + +/// A single usage snapshot, mirroring the payload the BLE daemon sends to the +/// physical device (see daemon/claude_usage_daemon.py `poll_api`). +struct Usage { + var fiveHourPct: Int // 5h unified utilization, 0-100 + var fiveHourResetMin: Int // minutes until the 5h window resets + var weekPct: Int // 7d unified utilization, 0-100 + var weekResetMin: Int // minutes until the 7d window resets + var status: String // 5h status (e.g. "allowed", "rejected") + + // Overage: consumo extra sobre el plan (puede superar el 100%). + var overagePct: Int + var overageStatus: String // "allowed" | "rejected" + var overageResetMin: Int + var overageDisabledReason: String // p.ej. "org_spend_cap_reached", o "" + + var overallStatus: String // anthropic-ratelimit-unified-status + var representativeClaim: String // límite vinculante: "five_hour" | "seven_day" | ... + + /// ¿La ventana de 5h es la que actualmente aprieta? + var fiveHourIsBinding: Bool { representativeClaim.contains("five") } + /// ¿La ventana de 7d es la que actualmente aprieta? + var weekIsBinding: Bool { representativeClaim.contains("seven") || representativeClaim.contains("7") } + /// Mostrar el medidor de overage solo si hay algo que decir. + var overageRelevant: Bool { overagePct > 0 || overageStatus == "rejected" } +} + +enum UsageError: Error, CustomStringConvertible { + case noToken + case http(Int, String) + case transport(String) + + var description: String { + switch self { + case .noToken: + return "No se pudo leer el token de Claude Code (Keychain)." + case let .http(code, body): + return "API HTTP \(code): \(body)" + case let .transport(msg): + return "Fallo de red: \(msg)" + } + } +} + +/// Reads the Claude Code OAuth access token from the macOS Keychain, the same +/// way the daemon does: `security find-generic-password -s "Claude Code-credentials"`. +enum TokenReader { + static let keychainService = "Claude Code-credentials" + + static func read() -> String? { + guard let blob = runSecurity() else { return nil } + return extractAccessToken(from: blob) + } + + private static func runSecurity() -> String? { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/security") + task.arguments = [ + "find-generic-password", + "-s", keychainService, + "-a", NSUserName(), + "-w", + ] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = Pipe() + do { + try task.run() + } catch { + return nil + } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + guard task.terminationStatus == 0 else { return nil } + return String(data: data, encoding: .utf8) + } + + /// The Keychain blob is usually JSON. It may be flat ({"accessToken": ...}) + /// or nested ({"claudeAiOauth": {"accessToken": ...}}). Fall back to a regex, + /// then to treating the blob as a raw token. + static func extractAccessToken(from raw: String) -> String? { + let blob = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if blob.isEmpty { return nil } + + if let data = blob.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let token = obj["accessToken"] as? String { return token } + for value in obj.values { + if let nested = value as? [String: Any], + let token = nested["accessToken"] as? String { + return token + } + } + } + + if let range = blob.range(of: #""accessToken"\s*:\s*"([^"]+)""#, options: .regularExpression) { + let match = String(blob[range]) + if let inner = match.range(of: #""([^"]+)"$"#, options: .regularExpression) { + return String(match[inner]).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + } + + // Last resort: a bare token has no whitespace and no JSON braces. + if !blob.contains(where: { $0 == "{" || $0 == " " }) { + return blob + } + return nil + } +} + +/// Pings the Anthropic API with a 1-token request and reads the unified +/// rate-limit headers off the response — the same trick the daemon uses. +enum UsagePoller { + static let apiURL = URL(string: "https://api.anthropic.com/v1/messages")! + + static func fetch() async throws -> Usage { + guard let token = TokenReader.read() else { throw UsageError.noToken } + + var request = URLRequest(url: apiURL) + request.httpMethod = "POST" + request.timeoutInterval = 20 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + request.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("claude-code/2.1.5", forHTTPHeaderField: "User-Agent") + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "model": "claude-haiku-4-5-20251001", + "max_tokens": 1, + "messages": [["role": "user", "content": "hi"]], + ]) + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw UsageError.transport(error.localizedDescription) + } + + guard let http = response as? HTTPURLResponse else { + throw UsageError.transport("respuesta no HTTP") + } + if http.statusCode >= 400 { + let body = String(data: data, encoding: .utf8) ?? "" + throw UsageError.http(http.statusCode, String(body.prefix(200))) + } + + func header(_ name: String) -> String { + (http.value(forHTTPHeaderField: name)) ?? "" + } + func pct(_ util: String) -> Int { + guard let v = Double(util) else { return 0 } + return Int((v * 100).rounded()) + } + func resetMinutes(_ ts: String) -> Int { + guard let r = Double(ts) else { return 0 } + let mins = (r - Date().timeIntervalSince1970) / 60.0 + return mins > 0 ? Int(mins.rounded()) : 0 + } + + func nonEmpty(_ name: String, _ fallback: String = "unknown") -> String { + let s = header(name) + return s.isEmpty ? fallback : s + } + + return Usage( + fiveHourPct: pct(header("anthropic-ratelimit-unified-5h-utilization")), + fiveHourResetMin: resetMinutes(header("anthropic-ratelimit-unified-5h-reset")), + weekPct: pct(header("anthropic-ratelimit-unified-7d-utilization")), + weekResetMin: resetMinutes(header("anthropic-ratelimit-unified-7d-reset")), + status: nonEmpty("anthropic-ratelimit-unified-5h-status"), + overagePct: pct(header("anthropic-ratelimit-unified-overage-utilization")), + overageStatus: nonEmpty("anthropic-ratelimit-unified-overage-status"), + overageResetMin: resetMinutes(header("anthropic-ratelimit-unified-overage-reset")), + overageDisabledReason: header("anthropic-ratelimit-unified-overage-disabled-reason"), + overallStatus: nonEmpty("anthropic-ratelimit-unified-status"), + representativeClaim: header("anthropic-ratelimit-unified-representative-claim") + ) + } +} diff --git a/macos-app/Sources/Clawdmeter/main.swift b/macos-app/Sources/Clawdmeter/main.swift new file mode 100644 index 0000000..5d6a1a7 --- /dev/null +++ b/macos-app/Sources/Clawdmeter/main.swift @@ -0,0 +1,70 @@ +import AppKit +import SwiftUI +import Combine + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + private var statusItem: NSStatusItem! + private let popover = NSPopover() + private let store = UsageStore() + private var cancellables = Set() + private var eventMonitor: Any? + + func applicationDidFinishLaunching(_ notification: Notification) { + // Menu-bar-only app: no Dock icon, no main window. + NSApp.setActivationPolicy(.accessory) + + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if let button = statusItem.button { + button.image = NSImage(systemSymbolName: "gauge.with.dots.needle.33percent", + accessibilityDescription: "Clawdmeter") + button.imagePosition = .imageLeading + button.title = " –" + button.action = #selector(togglePopover) + button.target = self + } + + popover.behavior = .transient + popover.contentViewController = NSHostingController(rootView: PopoverView(store: store)) + + // Reflect the latest 5h usage in the menu-bar title. + store.$usage + .receive(on: RunLoop.main) + .sink { [weak self] usage in + guard let button = self?.statusItem.button else { return } + if let usage { + button.title = " \(usage.fiveHourPct)%" + } else { + button.title = " –" + } + } + .store(in: &cancellables) + + store.$lastError + .receive(on: RunLoop.main) + .sink { [weak self] err in + guard let button = self?.statusItem.button, err != nil, self?.store.usage == nil else { return } + button.title = " !" + } + .store(in: &cancellables) + + store.start() + } + + @objc private func togglePopover() { + guard let button = statusItem.button else { return } + if popover.isShown { + popover.performClose(nil) + } else { + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + popover.contentViewController?.view.window?.makeKey() + } + } +} + +MainActor.assumeIsolated { + let app = NSApplication.shared + let delegate = AppDelegate() + app.delegate = delegate + app.run() +} diff --git a/macos-app/build.sh b/macos-app/build.sh new file mode 100755 index 0000000..ffc86de --- /dev/null +++ b/macos-app/build.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Builds Clawdmeter.app — a menu-bar-only macOS app bundle. +# Usage: ./build.sh → produces ./Clawdmeter.app +set -euo pipefail + +cd "$(dirname "$0")" + +APP_NAME="Clawdmeter" +BUNDLE="$APP_NAME.app" +BUNDLE_ID="cl.redcomercio.clawdmeter" +VERSION="1.0.0" + +echo "[1/3] Compiling (release)..." +swift build -c release + +BIN="$(swift build -c release --show-bin-path)/$APP_NAME" + +echo "[2/3] Assembling $BUNDLE ..." +rm -rf "$BUNDLE" +mkdir -p "$BUNDLE/Contents/MacOS" "$BUNDLE/Contents/Resources" +cp "$BIN" "$BUNDLE/Contents/MacOS/$APP_NAME" + +cat > "$BUNDLE/Contents/Info.plist" < + + + + CFBundleName $APP_NAME + CFBundleDisplayName $APP_NAME + CFBundleIdentifier $BUNDLE_ID + CFBundleVersion $VERSION + CFBundleShortVersionString$VERSION + CFBundlePackageType APPL + CFBundleExecutable $APP_NAME + LSMinimumSystemVersion 13.0 + LSUIElement + NSHumanReadableCopyrightClawdmeter + + +PLIST + +echo "[3/3] Ad-hoc code signing..." +codesign --force --deep --sign - "$BUNDLE" 2>/dev/null || echo " (codesign opcional omitido)" + +echo "" +echo "Listo: $(pwd)/$BUNDLE" +echo "Abrir: open $BUNDLE" +echo "Instalar: cp -r $BUNDLE /Applications/ (luego agregar a Items de inicio)"