diff --git a/Resources/Info.plist b/Resources/Info.plist index 9ece575..4aa1508 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.1.16 + 0.1.17 CFBundleVersion - 16 + 17 LSMinimumSystemVersion 14.0 LSUIElement diff --git a/Sources/CodexToolbar/Networking/CodexAppServerClient.swift b/Sources/CodexToolbar/Networking/CodexAppServerClient.swift index d090971..2276de8 100644 --- a/Sources/CodexToolbar/Networking/CodexAppServerClient.swift +++ b/Sources/CodexToolbar/Networking/CodexAppServerClient.swift @@ -461,12 +461,12 @@ actor CodexAppServerClient: CodexRateLimitClient { .filter { !$0.isEmpty } .joined(separator: "\n") - let lowercased = combined.lowercased() - if lowercased.contains("not logged in") - || lowercased.contains("operation not permitted") - || lowercased.contains("permission denied") - || lowercased.contains("os error 1") - { + // Only an explicit login-status result establishes that the user is + // signed out. Access failures cannot tell us whether credentials exist. + let reportsLoggedOut = combined.split(whereSeparator: \.isNewline).contains { + $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "not logged in" + } + if reportsLoggedOut { return .loggedOut } diff --git a/Tests/CodexToolbarTests/CodexAppServerClientTests.swift b/Tests/CodexToolbarTests/CodexAppServerClientTests.swift index 19028b9..bd20a3b 100644 --- a/Tests/CodexToolbarTests/CodexAppServerClientTests.swift +++ b/Tests/CodexToolbarTests/CodexAppServerClientTests.swift @@ -88,7 +88,7 @@ final class CodexAppServerClientTests: XCTestCase { XCTAssertEqual(status, .loggedOut) } - func testParseLoginStatusTreatsPermissionErrorsAsLoggedOut() { + func testParseLoginStatusTreatsPermissionErrorsAsIndeterminate() { let status = CodexAppServerClient.parseLoginStatus( exitStatus: 1, stdout: "", @@ -96,7 +96,23 @@ final class CodexAppServerClientTests: XCTestCase { timedOut: false ) - XCTAssertEqual(status, .loggedOut) + XCTAssertEqual(status, .indeterminate("Error checking login status: Operation not permitted (os error 1)")) + } + + func testParseLoginStatusDoesNotInferLogoutFromDiagnosticText() { + for diagnostic in ["Permission denied", "os error 1", "Unable to determine whether user is not logged in"] { + XCTAssertEqual( + CodexAppServerClient.parseLoginStatus(exitStatus: 1, stdout: "", stderr: diagnostic, timedOut: false), + .indeterminate(diagnostic) + ) + } + } + + func testParseLoginStatusTimeoutDoesNotEstablishLogout() { + XCTAssertEqual( + CodexAppServerClient.parseLoginStatus(exitStatus: 1, stdout: "Not logged in", stderr: "", timedOut: true), + .indeterminate("Timed out checking Codex login status.") + ) } func testParseLoginStatusTreatsUnknownFailuresAsIndeterminate() { diff --git a/Tests/CodexToolbarTests/RateLimitStoreTests.swift b/Tests/CodexToolbarTests/RateLimitStoreTests.swift index cecf904..3ff9e13 100644 --- a/Tests/CodexToolbarTests/RateLimitStoreTests.swift +++ b/Tests/CodexToolbarTests/RateLimitStoreTests.swift @@ -909,6 +909,57 @@ final class RateLimitStoreTests: XCTestCase { XCTAssertEqual(client.loadSnapshotCallCount, 2) } + func testManualRefreshPermissionFailureDoesNotReportSignInOrDiscardCards() async { + let client = FakeCodexRateLimitClient() + let permissionStatus = CodexAppServerClient.parseLoginStatus( + exitStatus: 1, stdout: "", + stderr: "Error checking login status: Permission denied (os error 13)", + timedOut: false + ) + client.loginStatusResults = [.success(.loggedIn), .success(permissionStatus)] + let store = RateLimitStore(client: client, reconnectDelayNanoseconds: 10_000_000_000, + refreshDelayNanosecondsProvider: { 10_000_000_000 }) + await store.start() + let initialRemaining = store.cards.map(\.remainingPercent) + await store.refreshNow() + + XCTAssertEqual(client.loadSnapshotCallCount, 2) + XCTAssertEqual(store.state, .ready) + XCTAssertEqual(store.cards.map(\.remainingPercent), initialRemaining) + XCTAssertNil(store.staleMessage) + await store.stop() + } + + func testTimeoutWithPermissionFailurePreservesStaleDataAndRecovers() async { + let client = FakeCodexRateLimitClient() + let permissionStatus = CodexAppServerClient.parseLoginStatus( + exitStatus: 1, stdout: "", + stderr: "Error checking login status: Operation not permitted (os error 1)", + timedOut: false + ) + client.loginStatusResults = [.success(.loggedIn), .success(.loggedIn), .success(permissionStatus)] + let store = RateLimitStore(client: client, reconnectDelayNanoseconds: 10_000_000_000, + refreshDelayNanosecondsProvider: { 10_000_000_000 }) + await store.start() + let initialCards = store.cards + let initialLastUpdated = store.lastUpdated + let timeoutMessage = "Timed out reading Codex rate limits." + client.loadSnapshotError = CodexAppServerError.serverError(timeoutMessage) + await store.refreshNow() + + XCTAssertEqual(store.state, .error(timeoutMessage)) + XCTAssertEqual(store.statusBarAccessibilityText, timeoutMessage) + XCTAssertEqual(store.staleMessage, timeoutMessage) + XCTAssertEqual(store.cards, initialCards) + XCTAssertEqual(store.lastUpdated, initialLastUpdated) + + client.loadSnapshotError = nil + await store.refreshNow() + XCTAssertEqual(store.state, .ready) + XCTAssertNil(store.staleMessage) + await store.stop() + } + func testStartupIgnoresRequiresOpenaiAuthWhenAccountIsPresent() async { let client = FakeCodexRateLimitClient() client.accountResponse = GetAccountResponse(