From f44e0044d6a275da772a5446f4c81dc09af409e6 Mon Sep 17 00:00:00 2001 From: justin Date: Thu, 21 May 2026 11:20:46 -0400 Subject: [PATCH 1/7] Route mDNS discoveries to the matching provider URL slot With the daemon now advertising as codexmeter-{provider} (codex/claude) instead of a shared "codexmeter" name, iOS was still assigning every discovery to codexServerURL. Whichever daemon's advertisement arrived first would land in that slot, so the codex display could end up pointing at the claude daemon (or vice versa) and both providers would render identical data. processDiscovery now parses the provider from the service instance name suffix: - codexmeter-codex -> codexServerURL (authoritative; overrides stale) - codexmeter-claude -> claudeServerURL (authoritative; overrides stale) - legacy codexmeter (no suffix) -> codexServerURL only if empty, so it doesn't stomp a real codexmeter-codex that came first. The "override stale value" behavior is needed to recover users whose codexServerURL was captured before per-provider names existed and now points to the wrong daemon. Co-Authored-By: Claude Opus 4.7 --- .../ViewModels/MeterViewModel.swift | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift b/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift index 65ef4b6..afa865e 100644 --- a/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift +++ b/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift @@ -120,14 +120,43 @@ 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 + // suffix. The new daemons advertise as "codexmeter-codex" / + // "codexmeter-claude" — that 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 legacy "codexmeter" + // name (no suffix) only fills the codex slot if it's empty so we + // don't stomp on a real "codexmeter-codex" assignment that may have + // arrived first. + let hasProviderSuffix = lower.contains("codexmeter-") + let provider: UsageProviderKind = lower.contains("claude") ? .claude : .codex + var didAssign = false + switch provider { + case .codex: + if hasProviderSuffix { + if codexServerURL != url { + codexServerURL = url + didAssign = true + } + } else if codexServerURL.isEmpty { + codexServerURL = url + didAssign = true + } + case .claude: + // Claude is only inferred from the suffix, so this branch is + // always authoritative. + if claudeServerURL != url { + claudeServerURL = url + didAssign = true + } + } + if didAssign { // Auto-start: this was an mDNS discovery, kick off fetch + timers beginPolling() } From 1d1b80314aeede252ba9c613d06eb9ec2a89fab1 Mon Sep 17 00:00:00 2001 From: justin Date: Thu, 21 May 2026 11:25:38 -0400 Subject: [PATCH 2/7] Tighten mDNS routing to exact provider suffixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous routing used substring tests ("claude" in name → claude, otherwise codex), which would mis-route unknown provider variants such as a "codexmeter-opencode" or "codexmeter-test" announcement into the codex slot and stomp the real codex URL. Now match exact instance names: codexmeter-codex, codexmeter-claude, or bare codexmeter (legacy, codex slot, fill-if-empty only). Anything else still appears in discoveredServices but is not assigned to a provider URL slot. Verified against six scenarios (fresh, stale-correction, legacy respect, legacy fill, unknown-variant ignore, case-insensitive). Co-Authored-By: Claude Opus 4.7 --- .../ViewModels/MeterViewModel.swift | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift b/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift index afa865e..2f55de9 100644 --- a/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift +++ b/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift @@ -126,20 +126,37 @@ final class MeterViewModel: ObservableObject { if !self.discoveredServices.contains(svc) { self.discoveredServices.append(svc) } - // Route to the matching provider slot based on the mDNS instance name - // suffix. The new daemons advertise as "codexmeter-codex" / - // "codexmeter-claude" — that 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 legacy "codexmeter" - // name (no suffix) only fills the codex slot if it's empty so we - // don't stomp on a real "codexmeter-codex" assignment that may have - // arrived first. - let hasProviderSuffix = lower.contains("codexmeter-") - let provider: UsageProviderKind = lower.contains("claude") ? .claude : .codex + // 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 switch provider { case .codex: - if hasProviderSuffix { + if authoritative { if codexServerURL != url { codexServerURL = url didAssign = true @@ -149,12 +166,12 @@ final class MeterViewModel: ObservableObject { didAssign = true } case .claude: - // Claude is only inferred from the suffix, so this branch is - // always authoritative. if claudeServerURL != url { claudeServerURL = url didAssign = true } + case .none: + break } if didAssign { // Auto-start: this was an mDNS discovery, kick off fetch + timers From bdc2912dacd01ff05ccca0cee16b2994f90d6f94 Mon Sep 17 00:00:00 2001 From: justin Date: Thu, 21 May 2026 11:28:07 -0400 Subject: [PATCH 3/7] Recreate NetServiceBrowser on every browse to avoid invalid reuse Foundation raises "invalid reuse after initialization failure" if the same NetServiceBrowser is reused after its previous search ended in a failed state (e.g. didNotSearch). With per-provider mDNS the browser runs through more start/stop cycles and is more likely to hit it. Replace the shared NetServiceBrowser with a var that we drop and recreate inside startBrowsing(). Also stop+detach any in-flight NetService resolves in stopBrowsing() so they aren't reused either. Co-Authored-By: Claude Opus 4.7 --- .../CodexMeterApp/Services/MDNSBrowser.swift | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/ios/CodexMeterApp/CodexMeterApp/Services/MDNSBrowser.swift b/ios/CodexMeterApp/CodexMeterApp/Services/MDNSBrowser.swift index 0460541..930557d 100644 --- a/ios/CodexMeterApp/CodexMeterApp/Services/MDNSBrowser.swift +++ b/ios/CodexMeterApp/CodexMeterApp/Services/MDNSBrowser.swift @@ -11,11 +11,15 @@ final class MDNSServiceBrowser: NSObject { private let serviceType = "_http._tcp." private let domain = "local." - private let netServiceBrowser = NetServiceBrowser() + // Recreated on each startBrowsing() — Foundation throws "invalid reuse + // after initialization failure" when the same NetServiceBrowser is + // reused after didNotSearch/stop in some states. A fresh browser per + // start avoids that entirely. + private var netServiceBrowser = NetServiceBrowser() private var servicesResolving = Set() private var isBrowsing = false private var discoveredURLs = Set() - + private func isCodexMeterService(_ service: NetService) -> Bool { service.name.lowercased().contains("codexmeter") } @@ -30,7 +34,14 @@ final class MDNSServiceBrowser: NSObject { guard !self.isBrowsing else { return } self.isBrowsing = true self.discoveredURLs.removeAll() - self.netServiceBrowser.searchForServices(ofType: self.serviceType, inDomain: self.domain) + // Drop the prior browser (which may be in a failed state) and + // create a fresh one for this browse session. + self.netServiceBrowser.delegate = nil + self.netServiceBrowser.stop() + let browser = NetServiceBrowser() + browser.delegate = self + self.netServiceBrowser = browser + browser.searchForServices(ofType: self.serviceType, inDomain: self.domain) } } @@ -39,6 +50,11 @@ final class MDNSServiceBrowser: NSObject { guard self.isBrowsing else { return } self.isBrowsing = false self.netServiceBrowser.stop() + // Cancel any in-flight resolves so we don't try to reuse them. + for service in self.servicesResolving { + service.delegate = nil + service.stop() + } self.servicesResolving.removeAll() self.discoveredURLs.removeAll() } From 8d6aa79cbd801009d8a960876aacf2de6f014f8a Mon Sep 17 00:00:00 2001 From: justin Date: Thu, 21 May 2026 11:31:02 -0400 Subject: [PATCH 4/7] Invalidate widget cache + reload timelines on URL change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When mDNS routing corrects a stale provider URL (e.g. codexServerURL was pointing at the claude daemon from the pre-fix bug period), the cached JSON in UserDefaults.sharedGroup is still wrong-provider data. The widget would keep rendering it until its next live fetch, which the user saw as both Codex and Claude tiles showing identical claude numbers (0% / 91% / reset 0m). processDiscovery now: - Detects when a slot's URL changed (vs. first-time set). - Clears the per-provider cached JSON + timestamp from the shared app group (and the legacy "last_usage_json" fallback for the codex slot). - Triggers an immediate fetchUsage so the correct payload lands ASAP. - Calls WidgetCenter.reloadAllTimelines() so the widget re-renders. After this change, launching the app once on the new build is enough for the widget to self-heal — no manual UserDefaults clearing needed. Co-Authored-By: Claude Opus 4.7 --- .../ViewModels/MeterViewModel.swift | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift b/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift index 2f55de9..fb1730d 100644 --- a/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift +++ b/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift @@ -154,10 +154,12 @@ final class MeterViewModel: ObservableObject { authoritative = false } var didAssign = false + var urlChanged = false switch provider { case .codex: if authoritative { if codexServerURL != url { + urlChanged = !codexServerURL.isEmpty codexServerURL = url didAssign = true } @@ -167,6 +169,7 @@ final class MeterViewModel: ObservableObject { } case .claude: if claudeServerURL != url { + urlChanged = !claudeServerURL.isEmpty claudeServerURL = url didAssign = true } @@ -174,11 +177,39 @@ final class MeterViewModel: ObservableObject { 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 { From 67ef4b94c81d022c478d4b82d01ce9887de23f66 Mon Sep 17 00:00:00 2001 From: justin Date: Thu, 21 May 2026 11:35:12 -0400 Subject: [PATCH 5/7] Replace NetService browser with NWBrowser to fix invalid-reuse crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix (recreate NetServiceBrowser on each start) addressed one source of "invalid reuse after initialization failure", but the NSException is also raised by NetService.resolve(withTimeout:) when Apple's browser re-delivers an already-resolved or already-failed NetService instance. We were calling resolve on it again, which re-tripped the exception. Rewrite MDNSServiceBrowser using Network.framework's NWBrowser + NWConnection. The modern API doesn't surface the legacy NSException failure modes at all: - NWBrowser handles re-announcements internally without re-delivering state objects to the caller. - NWConnection resolves the endpoint to host:port via its path, and cancel/restart is well-defined — no "reuse" hazard. Public API (startBrowsing, stopBrowsing, discoveryPublisher, discoveriesAsync, firstDiscovery) is unchanged so callers don't move. Co-Authored-By: Claude Opus 4.7 --- .../CodexMeterApp/Services/MDNSBrowser.swift | 239 +++++++++--------- 1 file changed, 119 insertions(+), 120 deletions(-) diff --git a/ios/CodexMeterApp/CodexMeterApp/Services/MDNSBrowser.swift b/ios/CodexMeterApp/CodexMeterApp/Services/MDNSBrowser.swift index 930557d..43f9b78 100644 --- a/ios/CodexMeterApp/CodexMeterApp/Services/MDNSBrowser.swift +++ b/ios/CodexMeterApp/CodexMeterApp/Services/MDNSBrowser.swift @@ -1,103 +1,155 @@ import Foundation +import Network @preconcurrency import Combine -import Darwin -/// MDNSBrowser discovers HTTP services on the local network and publishes resolved URLs. +/// MDNSBrowser discovers HTTP services on the local network and publishes +/// resolved URLs. +/// +/// Implementation note: this uses Network.framework's `NWBrowser` rather +/// than the legacy `NetServiceBrowser`/`NetService` APIs. The legacy APIs +/// raise NSExceptions like "invalid reuse after initialization failure" if +/// `NetService.resolve(withTimeout:)` is called on a service that's already +/// in a finished/failed state — and Apple's browser readily re-delivers the +/// same `NetService` instance on re-announcement, which would trip that +/// every time. `NWBrowser` doesn't expose that failure mode at all. final class MDNSServiceBrowser: NSObject { static let shared = MDNSServiceBrowser() - /// Publishes (url, name) for each discovered service. + /// Publishes (url, name) for each discovered service. Always delivered + /// on the main queue. let discoveryPublisher = PassthroughSubject<(String, String), Never>() - private let serviceType = "_http._tcp." + private let serviceType = "_http._tcp" private let domain = "local." - // Recreated on each startBrowsing() — Foundation throws "invalid reuse - // after initialization failure" when the same NetServiceBrowser is - // reused after didNotSearch/stop in some states. A fresh browser per - // start avoids that entirely. - private var netServiceBrowser = NetServiceBrowser() - private var servicesResolving = Set() - private var isBrowsing = false - private var discoveredURLs = Set() + private let queue = DispatchQueue(label: "MDNSServiceBrowser", qos: .userInitiated) - private func isCodexMeterService(_ service: NetService) -> Bool { - service.name.lowercased().contains("codexmeter") - } + // All mutable state is accessed only on `queue`. + private var browser: NWBrowser? + private var isBrowsing = false + 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() - // Drop the prior browser (which may be in a failed state) and - // create a fresh one for this browse session. - self.netServiceBrowser.delegate = nil - self.netServiceBrowser.stop() - let browser = NetServiceBrowser() - browser.delegate = self - self.netServiceBrowser = browser - browser.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() - // Cancel any in-flight resolves so we don't try to reuse them. - for service in self.servicesResolving { - service.delegate = nil - service.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) - } - - private func urlString(from service: NetService) -> String? { - guard service.port > 0 else { return nil } + // MARK: - Discovery handling (runs on `queue`) - 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 {} @@ -128,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 @@ -147,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 - } -} From 108e0e4d30b37129a4fa5607082f984b0b4dbbb4 Mon Sep 17 00:00:00 2001 From: justin Date: Thu, 21 May 2026 11:41:00 -0400 Subject: [PATCH 6/7] Skip claude hook limits when reset times have already passed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook snapshot is the highest-priority claude source. Claude Code's statusline writes ~/.claude/plan-limits.json on every status-line tick with the current five_hour / seven_day used_percentage and resets_at, but it does NOT zero out used_percentage when the window actually rolls over — it just keeps reporting the last in-window numbers until the next session burns into the new window. So immediately after a boundary, the file shows e.g. "five_hour: 100% used, resets_at = 8 minutes ago" even though the window has reset. Result: the claude daemon was returning s:0, sr:0, status "5h 100% used" while the plan-usage scraper had already captured fresh data (s:48, sr:261, "5h 52% used"). The widget rendered the stale hook data and showed 0%/0m left on the claude tile. Fix: in claude_hook_limits_snapshot, if either window's resets_at is in the past, treat the file as logically stale and return None so the loop falls through to the plan-usage / rate-limit / activity sources that do have current numbers. Co-Authored-By: Claude Opus 4.7 --- daemon/codex-usage-daemon.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) 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)), From fbb1c355889b96ae5d950d93dd77bcd451b25fc1 Mon Sep 17 00:00:00 2001 From: justin Date: Thu, 21 May 2026 11:52:30 -0400 Subject: [PATCH 7/7] Add Playwright-based Claude plan usage scraper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing scripts/claude-plan-usage-cache.py extracts plan usage by parsing the visible text of claude.ai/settings/usage. That stopped matching as Claude's markup evolved — the daemon error log filled with "Claude usage text did not include recognizable plan usage" and the cached claude-plan-usage.json went stale, which is the upstream cause of the widget's claude tile showing wrong / stuck data. This new scraper goes through claude.ai's JSON API directly via a saved Playwright session: - `login` opens a real Chromium so the user signs in once; saves storage state to ~/.config/codexmeter/claude-state.json. - `scrape` (default) launches headless Chromium with that state and hits https://claude.ai/api/organizations/{org}/usage, then writes a normalized payload to ~/.config/codexmeter/claude-plan-usage.json in the exact shape claude_plan_usage_snapshot() in the daemon expects (session / weekly dicts with used_pct + reset_mins). - `print` does the scrape without writing the cache. The cache contract is unchanged, so the daemon picks it up automatically — no daemon changes required. Also picks up the Xcode-recommended project defaults bump (LastUpgradeCheck 1600 -> 2650, ENABLE_USER_SCRIPT_SANDBOXING, STRING_CATALOG_GENERATE_SYMBOLS) and matching scheme updates that Xcode applied during recent device builds. Co-Authored-By: Claude Opus 4.7 --- .../CodexMeterApp.xcodeproj/project.pbxproj | 6 +- .../xcschemes/CodexMeterApp.xcscheme | 16 +- .../xcschemes/CodexMeterAppTests.xcscheme | 16 +- scripts/claude-plan-usage-playwright.py | 192 ++++++++++++++++++ 4 files changed, 205 insertions(+), 25 deletions(-) create mode 100755 scripts/claude-plan-usage-playwright.py 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"> - - - - - - 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())