Skip to content

Commit 2d39111

Browse files
authored
Merge pull request #37 from BlockedPath/codex/pulldownrefresh
Fix pull-to-refresh so stale provider failures do not hide good usage
2 parents bac39da + 70c2d70 commit 2d39111

2 files changed

Lines changed: 148 additions & 21 deletions

File tree

ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ final class MeterViewModel: ObservableObject {
4040
@Published var isOK: Bool = false
4141

4242
private let ble = BLEManager()
43+
private let urlSession: URLSession
4344
private var fetchTimer: Timer?
4445
private var bleTimer: Timer?
4546
private var mdnsTask: Task<Void, Never>?
@@ -103,10 +104,12 @@ final class MeterViewModel: ObservableObject {
103104
}
104105
}
105106

106-
init() {
107+
init(urlSession: URLSession = .shared, startsDiscovery: Bool = true) {
108+
self.urlSession = urlSession
107109
ble.onRefreshRequested = { [weak self] in
108110
Task { @MainActor [weak self] in await self?.fetchUsage() }
109111
}
112+
guard startsDiscovery else { return }
110113
// Start an async task to consume async discoveries stream
111114
mdnsTask = Task { [weak self] in
112115
guard let self else { return }
@@ -255,37 +258,55 @@ final class MeterViewModel: ObservableObject {
255258
isFetching = true
256259
defer { isFetching = false }
257260

258-
do {
259-
var selectedJSON: String?
260-
var selectedStatusData: Data?
261-
for provider in UsageProviderKind.allCases {
261+
var selectedJSON: String?
262+
var selectedStatusData: Data?
263+
var selectedError: Error?
264+
265+
for provider in UsageProviderKind.allCases {
266+
do {
262267
guard let result = try await fetchUsagePayload(provider) else { continue }
263268
persist(json: result.json, statusData: result.statusData, provider: provider)
264269
if provider == selectedProvider {
265270
selectedJSON = result.json
266271
selectedStatusData = result.statusData
267272
}
273+
} catch {
274+
if provider == selectedProvider {
275+
selectedError = error
276+
}
268277
}
278+
}
269279

270-
guard let json = selectedJSON else {
271-
errorMessage = "Cannot reach \(selectedProvider.title) server"
280+
guard let json = selectedJSON else {
281+
if hasCurrentPayload {
282+
errorMessage = nil
283+
await fetchDaemonStatus()
272284
return
273285
}
274-
usageJSON = json
275-
lastUpdate = Date()
276-
errorMessage = nil
277-
278-
parseJSON(json)
279-
if let selectedStatusData {
280-
parseDaemonStatus(selectedStatusData)
281-
} else {
286+
if let selectedError {
287+
errorMessage = selectedError.localizedDescription
282288
await fetchDaemonStatus()
289+
} else {
290+
errorMessage = "Cannot reach \(selectedProvider.title) server"
283291
}
284-
sendToBLE()
285-
} catch {
286-
errorMessage = error.localizedDescription
292+
return
293+
}
294+
295+
usageJSON = json
296+
lastUpdate = Date()
297+
errorMessage = nil
298+
299+
parseJSON(json)
300+
if let selectedStatusData {
301+
parseDaemonStatus(selectedStatusData)
302+
} else {
287303
await fetchDaemonStatus()
288304
}
305+
sendToBLE()
306+
}
307+
308+
private var hasCurrentPayload: Bool {
309+
usageJSON.trimmingCharacters(in: .whitespacesAndNewlines) != "{}" || lastUpdate != nil
289310
}
290311

291312
private func fetchUsagePayload(_ provider: UsageProviderKind) async throws -> (json: String, statusData: Data?)? {
@@ -294,7 +315,7 @@ final class MeterViewModel: ObservableObject {
294315

295316
var request = URLRequest(url: usageURL)
296317
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
297-
let (data, response) = try await URLSession.shared.data(for: request)
318+
let (data, response) = try await urlSession.data(for: request)
298319
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
299320
return nil
300321
}
@@ -303,7 +324,7 @@ final class MeterViewModel: ObservableObject {
303324
if let statusURL = endpointURL("status", base: base) {
304325
var statusRequest = URLRequest(url: statusURL)
305326
statusRequest.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
306-
let (data, response) = try await URLSession.shared.data(for: statusRequest)
327+
let (data, response) = try await urlSession.data(for: statusRequest)
307328
statusData = (response as? HTTPURLResponse)?.statusCode == 200 ? data : nil
308329
} else {
309330
statusData = nil
@@ -359,7 +380,7 @@ final class MeterViewModel: ObservableObject {
359380
do {
360381
var request = URLRequest(url: requestURL)
361382
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
362-
let (data, response) = try await URLSession.shared.data(for: request)
383+
let (data, response) = try await urlSession.data(for: request)
363384
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { return }
364385
parseDaemonStatus(data)
365386
} catch {

ios/CodexMeterApp/CodexMeterAppTests/MeterDiscoveryTests.swift

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,110 @@ final class MeterDiscoveryTests: XCTestCase {
2424

2525
XCTAssertTrue(received)
2626
}
27+
28+
func testFetchUsageKeepsSelectedProviderWhenOtherProviderFails() async throws {
29+
let configuration = URLSessionConfiguration.ephemeral
30+
configuration.protocolClasses = [MeterMockURLProtocol.self]
31+
let session = URLSession(configuration: configuration)
32+
33+
MeterMockURLProtocol.handler = { request in
34+
let url = try XCTUnwrap(request.url)
35+
if url.host == "codex.local", url.path == "/usage" {
36+
return (
37+
HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!,
38+
#"{"s":45,"sr":120,"w":28,"wr":7200,"st":"$2.31 today","ok":true}"#.data(using: .utf8)!
39+
)
40+
}
41+
if url.host == "codex.local", url.path == "/status" {
42+
return (
43+
HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!,
44+
#"{"source":"codex_oauth","last_success_at":"now","payload_age_seconds":1,"uptime_seconds":60}"#.data(using: .utf8)!
45+
)
46+
}
47+
throw URLError(.cannotConnectToHost)
48+
}
49+
50+
let vm: MeterViewModel = await MainActor.run {
51+
let vm = MeterViewModel(urlSession: session, startsDiscovery: false)
52+
vm.codexServerURL = "http://codex.local"
53+
vm.claudeServerURL = "http://claude.local"
54+
vm.selectedProvider = .codex
55+
return vm
56+
}
57+
58+
await vm.fetchUsage()
59+
60+
await MainActor.run {
61+
XCTAssertNil(vm.errorMessage)
62+
XCTAssertEqual(vm.statusText, "$2.31 today")
63+
XCTAssertEqual(vm.sessionPct, 45)
64+
}
65+
}
66+
67+
func testFetchUsageKeepsCurrentPayloadWhenSelectedRefreshIsCancelled() async throws {
68+
let configuration = URLSessionConfiguration.ephemeral
69+
configuration.protocolClasses = [MeterMockURLProtocol.self]
70+
let session = URLSession(configuration: configuration)
71+
72+
MeterMockURLProtocol.handler = { request in
73+
let url = try XCTUnwrap(request.url)
74+
if url.host == "codex.local", url.path == "/status" {
75+
return (
76+
HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!,
77+
#"{"source":"codex_oauth","last_success_at":"now","payload_age_seconds":29,"uptime_seconds":60,"last_error":""}"#.data(using: .utf8)!
78+
)
79+
}
80+
throw URLError(.cancelled)
81+
}
82+
83+
let vm: MeterViewModel = await MainActor.run {
84+
let vm = MeterViewModel(urlSession: session, startsDiscovery: false)
85+
vm.codexServerURL = "http://codex.local"
86+
vm.selectedProvider = .codex
87+
vm.usageJSON = #"{"s":64,"sr":289,"w":94,"wr":8895,"st":"0 credits","ok":true}"#
88+
vm.lastUpdate = Date()
89+
vm.statusText = "0 credits"
90+
vm.sessionPct = 64
91+
return vm
92+
}
93+
94+
await vm.fetchUsage()
95+
96+
await MainActor.run {
97+
XCTAssertNil(vm.errorMessage)
98+
XCTAssertEqual(vm.statusText, "0 credits")
99+
XCTAssertEqual(vm.sessionPct, 64)
100+
XCTAssertEqual(vm.daemonLastError, "")
101+
}
102+
}
103+
}
104+
105+
private final class MeterMockURLProtocol: URLProtocol {
106+
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
107+
108+
override class func canInit(with request: URLRequest) -> Bool {
109+
true
110+
}
111+
112+
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
113+
request
114+
}
115+
116+
override func startLoading() {
117+
guard let handler = Self.handler else {
118+
client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
119+
return
120+
}
121+
122+
do {
123+
let (response, data) = try handler(request)
124+
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
125+
client?.urlProtocol(self, didLoad: data)
126+
client?.urlProtocolDidFinishLoading(self)
127+
} catch {
128+
client?.urlProtocol(self, didFailWithError: error)
129+
}
130+
}
131+
132+
override func stopLoading() {}
27133
}

0 commit comments

Comments
 (0)