From b6eb0679ee0a30584893758744ac054f1cd40357 Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 14 Sep 2026 00:13:16 +0300 Subject: [PATCH] fix: re-read Keychain on limit refresh and toggle the floating pet from the footer A still-valid cached Claude token hid an in-place account switch from the Refresh button, because only a 401 cleared the cache. User refresh now skips that cache. The popover footer can show the pet again after Hide, using the same floatingPetEnabled flag as Settings. --- .../Core/AntigravityRateLimitsProvider.swift | 5 +- .../PokeTokenBar/Core/KeychainAccess.swift | 6 + .../Core/OAuthLimitsProvider.swift | 15 ++- Sources/PokeTokenBar/UI/PopoverView.swift | 10 ++ .../CredentialSwitchCacheTests.swift | 112 ++++++++++++++++++ .../PopoverNavigationTests.swift | 24 ++++ docs/reference/defect-log.md | 8 ++ 7 files changed, 177 insertions(+), 3 deletions(-) diff --git a/Sources/PokeTokenBar/Core/AntigravityRateLimitsProvider.swift b/Sources/PokeTokenBar/Core/AntigravityRateLimitsProvider.swift index 0e4f1c84b..df130f078 100644 --- a/Sources/PokeTokenBar/Core/AntigravityRateLimitsProvider.swift +++ b/Sources/PokeTokenBar/Core/AntigravityRateLimitsProvider.swift @@ -36,7 +36,10 @@ public struct AntigravityRateLimitsProvider: AntigravityLimitsProviding, Sendabl } public func fetch(allowKeychainPrompt: Bool = false) async throws -> AntigravityRateLimitStatus { - let token = try await tokenCache.accessToken(allowKeychainPrompt: allowKeychainPrompt) + // Claude 와 같은 부류(#300): 사용자 갱신은 아직 유효한 메모리 토큰을 믿지 않는다. + let token = try await tokenCache.accessToken( + allowKeychainPrompt: allowKeychainPrompt, + bypassCache: allowKeychainPrompt) do { return try await fetchStatus(accessToken: token) } catch let error as LimitsError { diff --git a/Sources/PokeTokenBar/Core/KeychainAccess.swift b/Sources/PokeTokenBar/Core/KeychainAccess.swift index f420cb602..721560faf 100644 --- a/Sources/PokeTokenBar/Core/KeychainAccess.swift +++ b/Sources/PokeTokenBar/Core/KeychainAccess.swift @@ -25,11 +25,17 @@ enum KeychainAccessGate { /// 실제로 그 틈으로 자동 폴링이 키체인을 읽는 구현이 스위트 초록인 채 들어왔다(#210). enum KeychainReader { nonisolated(unsafe) private(set) static var queryCount = 0 + /// 테스트가 Keychain 항목을 주입한다. 설정돼 있으면 `SecItemCopyMatching` 을 부르지 않는다. + /// tearDown 에서 반드시 nil 로 되돌린다 — 남으면 다른 테스트가 실제 키체인을 못 본다. + nonisolated(unsafe) static var copyMatchingForTesting: (([String: Any], inout CFTypeRef?) -> OSStatus)? static func resetQueryCountForTesting() { queryCount = 0 } static func copyMatching(_ query: [String: Any], _ result: inout CFTypeRef?) -> OSStatus { queryCount += 1 + if let copyMatchingForTesting { + return copyMatchingForTesting(query, &result) + } return SecItemCopyMatching(query as CFDictionary, &result) } } diff --git a/Sources/PokeTokenBar/Core/OAuthLimitsProvider.swift b/Sources/PokeTokenBar/Core/OAuthLimitsProvider.swift index 42db3ff05..1aa62c92a 100644 --- a/Sources/PokeTokenBar/Core/OAuthLimitsProvider.swift +++ b/Sources/PokeTokenBar/Core/OAuthLimitsProvider.swift @@ -34,10 +34,19 @@ protocol ClaudeLimitsProviding: Sendable { /// 비공식 endpoint 이므로 실패해도 토큰 표시에는 영향 없음 (한도 섹션만 숨김). struct OAuthLimitsProvider: ClaudeLimitsProviding, Sendable { private static let usageURL = URL(string: "https://api.anthropic.com/api/oauth/usage")! - private let accessTokenCache = OAuthAccessTokenCache.shared + private let accessTokenCache: OAuthAccessTokenCache + + init(accessTokenCache: OAuthAccessTokenCache = .shared) { + self.accessTokenCache = accessTokenCache + } func fetch(allowKeychainPrompt: Bool = false) async throws -> LimitStatus { - let token = try await accessTokenCache.accessToken(allowKeychainPrompt: allowKeychainPrompt) + // 사용자 갱신은 캐시를 건너뛴다. 이전 계정의 토큰이 아직 유효하면 401 이 안 나고, + // 메모리 캐시가 Keychain 의 새 계정을 영원히 가린다(#300). 자동 폴은 false 그대로 — + // 키체인 다이얼로그를 띄우면 안 된다. + let token = try await accessTokenCache.accessToken( + allowKeychainPrompt: allowKeychainPrompt, + bypassCache: allowKeychainPrompt) var activeToken = token var status: LimitStatus do { @@ -178,6 +187,8 @@ actor OAuthAccessTokenCache { // → Keychain 읽기는 명시적 사용자 동작(설정/팝오버의 갱신 버튼, allowKeychainPrompt=true)에서만 // 수행한다. 파일이 유효 토큰을 들고 있으면 매 폴이 그걸 쓴다. 파일이 없거나 oauth 가 빠진 // 뒤에만 캐시가 버티고, 그것도 만료되면 한도는 stale 표시 후 사용자가 갱신한다. + // 그 사용자 갱신은 `bypassCache: true` 로 들어온다(#300) — 옛 토큰이 아직 유효해도 + // 여기서 캐시를 돌려주면 계정 전환이 버튼으로도 안 끝난다. // 자동 경로는 여기서 끝난다(키체인 미열람). 파일이 있는데 계정 OAuth 만 없으면 재로그인이 // 답이므로 그때만 안내를 바꾼다 — 판정은 이 분기 안에서 해야 사용자 경로가 파일을 두 번 읽지 않는다. guard allowKeychainPrompt else { diff --git a/Sources/PokeTokenBar/UI/PopoverView.swift b/Sources/PokeTokenBar/UI/PopoverView.swift index 8d6619e62..8b4d17400 100644 --- a/Sources/PokeTokenBar/UI/PopoverView.swift +++ b/Sources/PokeTokenBar/UI/PopoverView.swift @@ -781,6 +781,16 @@ struct PopoverView: View { } } Spacer() + // 끄기는 펫 우클릭. 켜기는 여기 — 숨기면 우클릭할 대상이 없다(#301). + // Settings 체크박스와 같은 `floatingPetEnabled`. 홈 탭에만 두면 도감에서 못 켠다. + Button { + store.floatingPetEnabled.toggle() + } label: { + Image(systemName: store.floatingPetEnabled ? "eye" : "eye.slash") + } + .buttonStyle(.borderless) + .help(store.floatingPetEnabled ? l.floatingPetHideLabel : l.floatingPetEnableLabel) + .accessibilityLabel(store.floatingPetEnabled ? l.floatingPetHideLabel : l.floatingPetEnableLabel) Button { nav.showSettings = true } label: { diff --git a/Tests/PokeTokenBarTests/CredentialSwitchCacheTests.swift b/Tests/PokeTokenBarTests/CredentialSwitchCacheTests.swift index 7ce1cbb84..47dcbaab1 100644 --- a/Tests/PokeTokenBarTests/CredentialSwitchCacheTests.swift +++ b/Tests/PokeTokenBarTests/CredentialSwitchCacheTests.swift @@ -21,6 +21,9 @@ final class CredentialSwitchCacheTests: XCTestCase { } override func tearDownWithError() throws { + KeychainReader.copyMatchingForTesting = nil + URLProtocol.unregisterClass(ClaudeUsageURLProtocol.self) + ClaudeUsageURLProtocol.reset() try? FileManager.default.removeItem(at: tempDir) tempDir = nil MockOAuthURLProtocol.reset() @@ -79,6 +82,62 @@ final class CredentialSwitchCacheTests: XCTestCase { XCTAssertEqual(KeychainReader.queryCount, 0) } + /// #300: Keychain-only install (no credentials file). The previous account's token is still + /// valid, so a 401 never arrives and the in-memory cache used to answer the Refresh button + /// forever. Restoring `bypassCache: false` on that path must fail this test. + func testManualRefreshRereadsKeychainWhenCachedTokenIsStillValid() async throws { + let file = tempDir.appendingPathComponent("credentials.json") + try writeClaudeCredentials(to: file, token: "token-account-a", subscription: "team") + let cache = OAuthAccessTokenCache(credentialsFileURL: file) + _ = try await cache.accessToken(allowKeychainPrompt: false) + try FileManager.default.removeItem(at: file) + + let savedGate = KeychainAccessGate.isDisabled + KeychainAccessGate.isDisabled = false + defer { KeychainAccessGate.isDisabled = savedGate } + + KeychainReader.resetQueryCountForTesting() + installKeychainToken("token-account-b", subscription: "team") + URLProtocol.registerClass(ClaudeUsageURLProtocol.self) + defer { + URLProtocol.unregisterClass(ClaudeUsageURLProtocol.self) + ClaudeUsageURLProtocol.reset() + KeychainReader.copyMatchingForTesting = nil + } + + let provider = OAuthLimitsProvider(accessTokenCache: cache) + _ = try await provider.fetch(allowKeychainPrompt: true) + + XCTAssertGreaterThan(KeychainReader.queryCount, 0, "Refresh must open Keychain, not the memory cache") + XCTAssertEqual( + ClaudeUsageURLProtocol.authorization, + "Bearer token-account-b", + "a still-valid cached token must not hide the account now in Keychain (#300)") + + KeychainReader.resetQueryCountForTesting() + let auto = try await cache.accessToken(allowKeychainPrompt: false) + XCTAssertEqual(auto, "token-account-b", "user refresh should have replaced the cache") + XCTAssertEqual(KeychainReader.queryCount, 0, "auto-poll must still not touch Keychain") + } + + /// Same class as #300 on the Antigravity user-refresh path. The Claude case above is the + /// live trigger; this keeps the one-line twin from being "fixed" back independently. + func testAntigravityUserRefreshAlsoBypassesStillValidCache() throws { + let source = try String( + contentsOf: URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/PokeTokenBar/Core/AntigravityRateLimitsProvider.swift"), + encoding: .utf8) + let fetch = source.range(of: "public func fetch(allowKeychainPrompt") + XCTAssertNotNil(fetch) + let body = String(source[(fetch?.lowerBound ?? source.startIndex)...].prefix(700)) + XCTAssertTrue( + body.contains("bypassCache: allowKeychainPrompt"), + "Antigravity user refresh must skip a still-valid cached token, same as Claude #300") + } + // MARK: Antigravity (same class) func testAntigravityAutoPollPicksUpTokenFileSwitch() async throws { @@ -292,6 +351,27 @@ final class CredentialSwitchCacheTests: XCTestCase { try Data(json.utf8).write(to: url, options: .atomic) } + private func installKeychainToken(_ token: String, subscription: String) { + let expiresAt = Int(Date().addingTimeInterval(3600).timeIntervalSince1970) + let json = """ + {"claudeAiOauth":{"accessToken":"\(token)","expiresAt":\(expiresAt),"subscriptionType":"\(subscription)"}} + """ + let data = Data(json.utf8) + KeychainReader.copyMatchingForTesting = { query, result in + if (query[kSecReturnData as String] as? Bool) == true { + result = data as CFData + return errSecSuccess + } + // Accounts query must succeed. errSecItemNotFound there is "no credential", + // not the single-item fallback (#232). + if (query[kSecReturnAttributes as String] as? Bool) == true { + result = [[kSecAttrAccount as String: "hello@example.com"]] as CFArray + return errSecSuccess + } + return errSecItemNotFound + } + } + private func writeAntigravityToken(to url: URL, token: String) throws { let json = "{\"token\":\"\(token)\"}" try Data(json.utf8).write(to: url, options: .atomic) @@ -374,3 +454,35 @@ final class MockOAuthURLProtocol: URLProtocol { return data } } + +/// Intercepts the usage call so the Refresh path can be asserted without a live token. +private final class ClaudeUsageURLProtocol: URLProtocol, @unchecked Sendable { + nonisolated(unsafe) static var authorization: String? + + static func reset() { authorization = nil } + + override class func canInit(with request: URLRequest) -> Bool { + request.url?.host == "api.anthropic.com" + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let url = request.url! + if url.path == "/api/oauth/usage" { + Self.authorization = request.value(forHTTPHeaderField: "Authorization") + let body = Data(#"{"five_hour":{"utilization":11},"seven_day":{"utilization":22}}"#.utf8) + let response = HTTPURLResponse( + url: url, statusCode: 200, httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: body) + } else { + let response = HTTPURLResponse(url: url, statusCode: 404, httpVersion: "HTTP/1.1", headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + } + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} diff --git a/Tests/PokeTokenBarTests/PopoverNavigationTests.swift b/Tests/PokeTokenBarTests/PopoverNavigationTests.swift index 9c2541da4..e7551e21c 100644 --- a/Tests/PokeTokenBarTests/PopoverNavigationTests.swift +++ b/Tests/PokeTokenBarTests/PopoverNavigationTests.swift @@ -34,6 +34,30 @@ final class PopoverNavigationTests: XCTestCase { XCTAssertEqual(nav.tab, .collection) XCTAssertFalse(nav.showingCollectionLog, "포획 로그에서 설정을 열었어도 대표 선택은 도감으로 이동") } + + /// #301: Hide is a right-click. Show has to live on the popover footer, bound to the same + /// `floatingPetEnabled` the Settings checkbox already uses. Removing the button must fail this. + func testPopoverFooterTogglesFloatingPetWithoutOpeningSettings() throws { + let source = try String(contentsOf: Self.popoverSource, encoding: .utf8) + let footer = try XCTUnwrap(source.range(of: "private var footer")) + let body = String(source[footer.lowerBound...]) + XCTAssertTrue( + body.contains("store.floatingPetEnabled.toggle()"), + "footer must flip floatingPetEnabled — Hide already does; Show had only Settings") + XCTAssertTrue(body.contains("l.floatingPetHideLabel")) + XCTAssertTrue(body.contains("l.floatingPetEnableLabel")) + XCTAssertFalse( + body.contains("nav.showSettings = true\n }\n .buttonStyle(.borderless)\n .help(l.floatingPet"), + "the pet control must not be a second door into Settings") + } + + private static let popoverSource: URL = { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/PokeTokenBar/UI/PopoverView.swift") + }() } final class RepresentativeLocalizationTests: XCTestCase { diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index 6bcd5ae4b..d6a09acf8 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -432,6 +432,14 @@ read_when: `jetski-standalone-oauth-token` 은 파일 로드 시 `expiresAt=nil` 이라 캐시가 만료로 풀리지 않는다. 회귀: `testClaudeAutoPollPicksUpInPlaceAccountSwitch`·`testAntigravityAutoPollPicksUpTokenFileSwitch`. 캐시-우선 early return 을 되돌리면 이 두 테스트가 실패해야 한다. +- **파일이 없는 Keychain-only 설치는 사용자 갱신도 만료만 보면 이전 계정에 붙는다.** #227 은 + 파일이 새 유효 토큰으로 덮이는 경우를 고쳤다. 파일이 없으면 같은 메모리 캐시가 남고, 같은 Team 의 + 다른 메일은 401 을 안 주므로 invalidate 경로에 안 들어간다. Refresh 를 눌러도 Keychain 을 안 읽고 + 로그는 "keychain"이라고 남긴다(#300). 사용자 동작(`allowKeychainPrompt: true`)만 + `bypassCache: true`. 자동 폴은 그대로 캐시 — 키체인 다이얼로그를 되살리면 #210 이 돌아온다. + 같은 한 줄: `AntigravityRateLimitsProvider.fetch`. 회귀: + `testManualRefreshRereadsKeychainWhenCachedTokenIsStillValid`. `bypassCache` 를 빼면 + Authorization 이 `token-account-a` 로 남고 Keychain 조회가 0 이다. - **`kSecMatchLimitAll` 과 `kSecReturnData` 는 같이 못 쓴다 — macOS 가 errSecParam(-50) 으로 거절한다.** "서비스에 항목이 여럿일 수 있으니 전부 받아서 유효한 걸 고르자"는 발상 자체는 옳지만(#229), 한 번의 `SecItemCopyMatching` 으로 *모든 항목의 데이터*를 받는 쿼리는 **파라미터 단계에서** 거절된다. 항목이