diff --git a/daemon/codex-usage-daemon.py b/daemon/codex-usage-daemon.py index a26cbd1..5e809f7 100755 --- a/daemon/codex-usage-daemon.py +++ b/daemon/codex-usage-daemon.py @@ -324,6 +324,15 @@ def minutes_until_epoch(epoch_seconds: Any) -> int: return max(0, round((reset - utc_now()).total_seconds() / 60)) +def epoch_seconds_in_past(epoch_seconds: Any, slack_seconds: int = 0) -> bool: + """True if `epoch_seconds` is more than `slack_seconds` in the past.""" + try: + reset = float(epoch_seconds) + except (TypeError, ValueError): + return False + return reset + slack_seconds < time.time() + + def minutes_until_local_time(hour: int, minute: int) -> int: now = datetime.now().astimezone() reset = now.replace(hour=hour, minute=minute, second=0, microsecond=0) @@ -817,8 +826,19 @@ def claude_hook_limits_snapshot() -> UsageSnapshot | None: if five_used is None or week_used is None: return None - five_reset = minutes_until_epoch(five_hour.get("resets_at")) - week_reset = minutes_until_epoch(seven_day.get("resets_at")) + # If either reset time has already passed, the percentages in this file + # are logically stale even though the file's mtime may be recent (claude + # code writes this on every status-line refresh but doesn't reset the + # percentages until the next real session crosses the boundary). Falling + # through here lets the plan-usage / activity sources provide fresh + # numbers instead of showing 100% used on a window that already reset. + five_resets_at = five_hour.get("resets_at") if five_hour else None + week_resets_at = seven_day.get("resets_at") if seven_day else None + if epoch_seconds_in_past(five_resets_at) or epoch_seconds_in_past(week_resets_at): + return None + + five_reset = minutes_until_epoch(five_resets_at) + week_reset = minutes_until_epoch(week_resets_at) return UsageSnapshot( session_pct=remaining_pct_from_used(float(five_used)), diff --git a/ios/CodexMeterApp/CodexMeterApp.xcodeproj/project.pbxproj b/ios/CodexMeterApp/CodexMeterApp.xcodeproj/project.pbxproj index 6ef5076..4cc68c9 100644 --- a/ios/CodexMeterApp/CodexMeterApp.xcodeproj/project.pbxproj +++ b/ios/CodexMeterApp/CodexMeterApp.xcodeproj/project.pbxproj @@ -199,7 +199,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1600; + LastUpgradeCheck = 2650; TargetAttributes = { 2DA90E0FD071B0F94B375F91 = { ProvisioningStyle = Automatic; @@ -428,6 +428,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -449,6 +450,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.codexmeter.ios; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -494,6 +496,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -508,6 +511,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.codexmeter.ios; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; diff --git a/ios/CodexMeterApp/CodexMeterApp.xcodeproj/xcshareddata/xcschemes/CodexMeterApp.xcscheme b/ios/CodexMeterApp/CodexMeterApp.xcodeproj/xcshareddata/xcschemes/CodexMeterApp.xcscheme index 07f8ff0..e7c7a31 100644 --- a/ios/CodexMeterApp/CodexMeterApp.xcodeproj/xcshareddata/xcschemes/CodexMeterApp.xcscheme +++ b/ios/CodexMeterApp/CodexMeterApp.xcodeproj/xcshareddata/xcschemes/CodexMeterApp.xcscheme @@ -1,11 +1,10 @@ + LastUpgradeVersion = "2650" + version = "1.3"> + buildImplicitDependencies = "YES"> + codeCoverageEnabled = "YES"> - - - - - - diff --git a/ios/CodexMeterApp/CodexMeterApp.xcodeproj/xcshareddata/xcschemes/CodexMeterAppTests.xcscheme b/ios/CodexMeterApp/CodexMeterApp.xcodeproj/xcshareddata/xcschemes/CodexMeterAppTests.xcscheme index 6741630..e43b48f 100644 --- a/ios/CodexMeterApp/CodexMeterApp.xcodeproj/xcshareddata/xcschemes/CodexMeterAppTests.xcscheme +++ b/ios/CodexMeterApp/CodexMeterApp.xcodeproj/xcshareddata/xcschemes/CodexMeterAppTests.xcscheme @@ -1,11 +1,10 @@ + LastUpgradeVersion = "2650" + version = "1.3"> + buildImplicitDependencies = "YES"> + codeCoverageEnabled = "YES"> - - - - - - () - private let serviceType = "_http._tcp." + private let serviceType = "_http._tcp" private let domain = "local." - private let netServiceBrowser = NetServiceBrowser() - private var servicesResolving = Set() + private let queue = DispatchQueue(label: "MDNSServiceBrowser", qos: .userInitiated) + + // All mutable state is accessed only on `queue`. + private var browser: NWBrowser? private var isBrowsing = false - private var discoveredURLs = Set() - - private func isCodexMeterService(_ service: NetService) -> Bool { - service.name.lowercased().contains("codexmeter") - } + private var resolvers: [NWEndpoint: NWConnection] = [:] + private var resolvedURLs: Set = [] private override init() { super.init() - netServiceBrowser.delegate = self } func startBrowsing() { - DispatchQueue.main.async { + queue.async { [weak self] in + guard let self else { return } guard !self.isBrowsing else { return } self.isBrowsing = true - self.discoveredURLs.removeAll() - self.netServiceBrowser.searchForServices(ofType: self.serviceType, inDomain: self.domain) + self.resolvedURLs.removeAll() + + let descriptor = NWBrowser.Descriptor.bonjour(type: self.serviceType, domain: self.domain) + let params = NWParameters() + params.includePeerToPeer = false + let browser = NWBrowser(for: descriptor, using: params) + browser.stateUpdateHandler = { [weak self] state in + guard let self else { return } + switch state { + case .failed, .cancelled: + self.isBrowsing = false + default: + break + } + } + browser.browseResultsChangedHandler = { [weak self] results, _ in + self?.handleResults(results) + } + self.browser = browser + browser.start(queue: self.queue) } } func stopBrowsing() { - DispatchQueue.main.async { + queue.async { [weak self] in + guard let self else { return } guard self.isBrowsing else { return } self.isBrowsing = false - self.netServiceBrowser.stop() - self.servicesResolving.removeAll() - self.discoveredURLs.removeAll() + self.browser?.cancel() + self.browser = nil + for conn in self.resolvers.values { conn.cancel() } + self.resolvers.removeAll() + self.resolvedURLs.removeAll() } } - private func cleanup(service: NetService) { - service.delegate = nil - servicesResolving.remove(service) - } + // MARK: - Discovery handling (runs on `queue`) - private func urlString(from service: NetService) -> String? { - guard service.port > 0 else { return nil } - - if let addresses = service.addresses { - for addressData in addresses { - if let ip = ipAddressFrom(addressData: addressData) { - return "http://\(ip):\(service.port)" - } + private func handleResults(_ results: Set) { + for result in results { + guard case let .service(name: name, type: _, domain: _, interface: _) = result.endpoint else { + continue } + guard name.lowercased().contains("codexmeter") else { continue } + // Skip if a resolver is already in flight for this endpoint. + guard resolvers[result.endpoint] == nil else { continue } + resolveEndpoint(result.endpoint, name: name) } - - guard let hostName = service.hostName, !hostName.isEmpty else { return nil } - return "http://\(hostName):\(service.port)" } - private func ipAddressFrom(addressData: Data) -> String? { - return addressData.withUnsafeBytes { (pointer: UnsafeRawBufferPointer) -> String? in - guard let sockaddrPtr = pointer.bindMemory(to: sockaddr.self).baseAddress else { - return nil + private func resolveEndpoint(_ endpoint: NWEndpoint, name: String) { + let connection = NWConnection(to: endpoint, using: .tcp) + resolvers[endpoint] = connection + + // Hard timeout — if we don't get a path within 5s, drop it. + queue.asyncAfter(deadline: .now() + 5) { [weak self, weak connection] in + guard let self else { return } + if self.resolvers[endpoint] != nil { + connection?.cancel() + self.resolvers.removeValue(forKey: endpoint) } - let family = sockaddrPtr.pointee.sa_family - if family == sa_family_t(AF_INET) { - let sockaddr4Ptr = UnsafeRawPointer(sockaddrPtr).assumingMemoryBound(to: sockaddr_in.self) - var addr = sockaddr4Ptr.pointee.sin_addr - var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) - let conversion = inet_ntop(AF_INET, &addr, &buffer, socklen_t(INET_ADDRSTRLEN)) - if conversion != nil { - return String(cString: buffer) + } + + connection.stateUpdateHandler = { [weak self, weak connection] state in + guard let self, let connection else { return } + switch state { + case .ready: + if let url = self.resolvedURL(from: connection) { + if !self.resolvedURLs.contains(url) { + self.resolvedURLs.insert(url) + DispatchQueue.main.async { [weak self] in + self?.discoveryPublisher.send((url, name)) + } + } } + connection.cancel() + self.resolvers.removeValue(forKey: endpoint) + case .failed, .cancelled: + self.resolvers.removeValue(forKey: endpoint) + default: + break } + } + connection.start(queue: queue) + } + + private func resolvedURL(from connection: NWConnection) -> String? { + guard let remote = connection.currentPath?.remoteEndpoint else { return nil } + guard case let .hostPort(host: host, port: port) = remote else { return nil } + let portValue = Int(port.rawValue) + switch host { + case .ipv4(let addr): + // Use the dotted-quad string. + return "http://\(ipv4String(addr)):\(portValue)" + case .ipv6(let addr): + return "http://[\(ipv6String(addr))]:\(portValue)" + case .name(let hostname, _): + return "http://\(hostname):\(portValue)" + @unknown default: return nil } } + + private func ipv4String(_ addr: IPv4Address) -> String { + let bytes = addr.rawValue + guard bytes.count == 4 else { return addr.debugDescription } + return "\(bytes[0]).\(bytes[1]).\(bytes[2]).\(bytes[3])" + } + + private func ipv6String(_ addr: IPv6Address) -> String { + return addr.debugDescription + } } extension MDNSServiceBrowser: @unchecked Sendable {} @@ -112,13 +180,12 @@ extension MDNSServiceBrowser { cancellable = discoveryPublisher .receive(on: DispatchQueue.main) .sink { pair in - if !resumed { - resumed = true - cancellable?.cancel() - cont.resume(returning: pair) + if !resumed { + resumed = true + cancellable?.cancel() + cont.resume(returning: pair) + } } - } - // Timeout handler DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { if !resumed { resumed = true @@ -131,55 +198,3 @@ extension MDNSServiceBrowser { } } } - -extension MDNSServiceBrowser: NetServiceBrowserDelegate { - func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) { - DispatchQueue.main.async { - if !self.servicesResolving.contains(service) { - self.servicesResolving.insert(service) - service.delegate = self - service.resolve(withTimeout: 5.0) - } - } - } - - func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) { - DispatchQueue.main.async { - self.servicesResolving.remove(service) - if let urlString = self.urlString(from: service) { - self.discoveredURLs.remove(urlString) - } - } - } - - func netServiceBrowserDidStopSearch(_ browser: NetServiceBrowser) { - DispatchQueue.main.async { self.isBrowsing = false } - } - - func netServiceBrowser(_ browser: NetServiceBrowser, didNotSearch errorDict: [String : NSNumber]) { - DispatchQueue.main.async { self.isBrowsing = false } - } -} - -extension MDNSServiceBrowser: NetServiceDelegate { - func netServiceDidResolveAddress(_ sender: NetService) { - DispatchQueue.main.async { - guard self.servicesResolving.contains(sender) else { self.cleanup(service: sender); return } - guard self.isCodexMeterService(sender) else { self.cleanup(service: sender); return } - guard let urlString = self.urlString(from: sender) else { self.cleanup(service: sender); return } - if !self.discoveredURLs.contains(urlString) { - self.discoveredURLs.insert(urlString) - self.discoveryPublisher.send((urlString, sender.name)) - } - self.cleanup(service: sender) - } - } - - func netService(_ sender: NetService, didNotResolve errorDict: [String : NSNumber]) { - DispatchQueue.main.async { self.cleanup(service: sender) } - } - - func netService(_ sender: NetService, didUpdateTXTRecord data: Data) { - // No-op - } -} diff --git a/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift b/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift index 65ef4b6..fb1730d 100644 --- a/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift +++ b/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift @@ -120,19 +120,96 @@ final class MeterViewModel: ObservableObject { @MainActor private func processDiscovery(url: String, name: String) { - guard name.lowercased().contains("codexmeter") else { return } + let lower = name.lowercased() + guard lower.contains("codexmeter") else { return } let svc = DiscoveredService(id: url, name: name, url: url) if !self.discoveredServices.contains(svc) { self.discoveredServices.append(svc) } - let wasEmpty = self.codexServerURL.isEmpty - if wasEmpty { - self.codexServerURL = url + // Route to the matching provider slot based on the mDNS instance + // name. Daemons advertise as "codexmeter-codex" / "codexmeter-claude" + // — the exact suffix is authoritative for which slot the URL belongs + // to, so we override any stale value (e.g. a URL captured before + // per-provider names existed). The bare legacy "codexmeter" name + // only fills the codex slot if it's empty so it can't stomp a real + // "codexmeter-codex" assignment that may have arrived first. + // Unknown variants (e.g. "codexmeter-opencode") are surfaced via + // discoveredServices but not routed to any provider slot. + let provider: UsageProviderKind? + let authoritative: Bool + switch lower { + case "codexmeter-codex": + provider = .codex + authoritative = true + case "codexmeter-claude": + provider = .claude + authoritative = true + case "codexmeter": + // Legacy single-name advertisement: fall back to codex but never + // override a real per-provider assignment. + provider = .codex + authoritative = false + default: + provider = nil + authoritative = false + } + var didAssign = false + var urlChanged = false + switch provider { + case .codex: + if authoritative { + if codexServerURL != url { + urlChanged = !codexServerURL.isEmpty + codexServerURL = url + didAssign = true + } + } else if codexServerURL.isEmpty { + codexServerURL = url + didAssign = true + } + case .claude: + if claudeServerURL != url { + urlChanged = !claudeServerURL.isEmpty + claudeServerURL = url + didAssign = true + } + case .none: + break + } + if didAssign { + if urlChanged, let provider { + // The slot was previously pointing at a different daemon (most + // commonly: a stale URL from when both daemons advertised as + // "codexmeter"). The cached JSON in the app group is therefore + // wrong-provider data — clear it so the widget doesn't keep + // rendering claude data in the codex tile (or vice versa) + // until the next live fetch lands. + invalidateCachedPayload(for: provider) + } // Auto-start: this was an mDNS discovery, kick off fetch + timers beginPolling() + // Also force an immediate fetch so the widget's shared-storage + // cache picks up correct data right away. + Task { @MainActor [weak self] in await self?.fetchUsage() } + WidgetCenter.shared.reloadAllTimelines() } } + /// Clear the stored per-provider payload + timestamp for the given + /// provider in the shared app group, plus the legacy "last_usage_json" + /// fallback if the codex slot is being invalidated. + @MainActor + private func invalidateCachedPayload(for provider: UsageProviderKind) { + let shared = UserDefaults.sharedGroup + shared.removeObject(forKey: provider.jsonKey) + shared.removeObject(forKey: "last_usage_updated_at_\(provider.rawValue)") + if provider == .codex { + shared.removeObject(forKey: "last_usage_json") + shared.removeObject(forKey: "last_usage_updated_at") + } + shared.synchronize() + } + func start() { // If no server configured, start mDNS discovery to auto-find the daemon. if codexServerURL.isEmpty && claudeServerURL.isEmpty { diff --git a/scripts/claude-plan-usage-playwright.py b/scripts/claude-plan-usage-playwright.py new file mode 100755 index 0000000..968863f --- /dev/null +++ b/scripts/claude-plan-usage-playwright.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Headless Playwright scrape of Claude plan usage from claude.ai/api. + +Subcommands: + login Open a real browser so you can log in to claude.ai; saves session + state to ~/.config/codexmeter/claude-state.json. + scrape (default) Launch headless Chromium with that state and hit + https://claude.ai/api/organizations/{org}/usage; write the + normalized cache file ~/.config/codexmeter/claude-plan-usage.json. + print Same as scrape but don't write the cache file. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from datetime import datetime, timezone +from pathlib import Path + +CONFIG_DIR = Path.home() / ".config" / "codexmeter" +STATE_PATH = Path( + os.getenv("CODEXMETER_PLAYWRIGHT_STATE", str(CONFIG_DIR / "claude-state.json")) +) +CACHE_PATH = Path( + os.getenv( + "CODEXMETER_CLAUDE_PLAN_USAGE_FILE", str(CONFIG_DIR / "claude-plan-usage.json") + ) +) +DEFAULT_UA = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36" +) + + +def claude_org_id() -> str: + env = os.getenv("CODEXMETER_CLAUDE_ORG_ID") + if env: + return env + profile = Path.home() / ".claude.json" + if profile.exists(): + try: + data = json.loads(profile.read_text(errors="replace")) + account = data.get("oauthAccount") or {} + org = account.get("organizationUuid") + if org: + return str(org) + except (OSError, json.JSONDecodeError): + pass + raise SystemExit( + "Could not determine Claude organization id. Set " + "CODEXMETER_CLAUDE_ORG_ID or log in to Claude Code so that " + "~/.claude.json has oauthAccount.organizationUuid." + ) + + +def minutes_until_iso(iso: str | None) -> int: + if not iso: + return -1 + try: + ts = iso.replace("Z", "+00:00") + when = datetime.fromisoformat(ts) + except ValueError: + return -1 + delta = (when - datetime.now(timezone.utc)).total_seconds() + return max(0, round(delta / 60)) + + +def _has_session_cookie(context) -> bool: + for cookie in context.cookies(): + name = cookie.get("name", "") + if name in {"sessionKey", "sessionKeyLC"} and cookie.get("value"): + return True + return False + + +def run_login() -> int: + from playwright.sync_api import sync_playwright + + STATE_PATH.parent.mkdir(parents=True, exist_ok=True) + timeout_seconds = int(os.getenv("CODEXMETER_LOGIN_TIMEOUT", "300")) + print( + f"Opening Chromium so you can log in to claude.ai. " + f"This window will close automatically once a session cookie is detected " + f"(timeout: {timeout_seconds}s)." + ) + with sync_playwright() as p: + browser = p.chromium.launch(headless=False) + try: + context = browser.new_context(user_agent=DEFAULT_UA) + page = context.new_page() + page.goto("https://claude.ai/login", wait_until="domcontentloaded") + deadline = datetime.now(timezone.utc).timestamp() + timeout_seconds + while datetime.now(timezone.utc).timestamp() < deadline: + if _has_session_cookie(context): + break + page.wait_for_timeout(1500) + else: + raise SystemExit("Login timed out before a session cookie appeared.") + context.storage_state(path=str(STATE_PATH)) + finally: + browser.close() + os.chmod(STATE_PATH, 0o600) + print(f"Saved session state to {STATE_PATH}") + return 0 + + +def fetch_usage() -> dict: + from playwright.sync_api import sync_playwright + + if not STATE_PATH.exists(): + raise SystemExit( + f"No saved session at {STATE_PATH}. Run: {sys.argv[0]} login" + ) + + org = claude_org_id() + api_url = f"https://claude.ai/api/organizations/{org}/usage" + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + try: + context = browser.new_context( + storage_state=str(STATE_PATH), + user_agent=DEFAULT_UA, + locale="en-US", + ) + page = context.new_page() + # Visit the origin first so Cloudflare cookies attach to the + # session before we call the API. + page.goto("https://claude.ai/", wait_until="domcontentloaded") + resp = context.request.get(api_url, headers={"Accept": "application/json"}) + if resp.status != 200: + raise SystemExit( + f"claude.ai/api returned HTTP {resp.status}: {resp.text()[:240]}" + ) + data = resp.json() + finally: + browser.close() + if not isinstance(data, dict): + raise SystemExit(f"unexpected usage payload: {data!r}") + return data + + +def normalize(data: dict) -> dict: + five = data.get("five_hour") if isinstance(data.get("five_hour"), dict) else {} + week = data.get("seven_day") if isinstance(data.get("seven_day"), dict) else {} + five_used = float(five.get("utilization") or 0) + week_used = float(week.get("utilization") or 0) + return { + "source": "claude_api_playwright", + "captured_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "session": { + "used_pct": int(round(five_used)), + "reset_mins": minutes_until_iso(five.get("resets_at")), + }, + "weekly": { + "used_pct": int(round(week_used)), + "reset_mins": minutes_until_iso(week.get("resets_at")), + }, + } + + +def write_cache(payload: dict) -> None: + CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + tmp = CACHE_PATH.with_suffix(".json.tmp") + tmp.write_text(json.dumps(payload, separators=(",", ":"))) + tmp.replace(CACHE_PATH) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "command", + nargs="?", + default="scrape", + choices=["login", "scrape", "print"], + ) + args = ap.parse_args() + + if args.command == "login": + return run_login() + + payload = normalize(fetch_usage()) + if args.command == "scrape": + write_cache(payload) + print(json.dumps(payload, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())