Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions Sources/PokeTokenBar/Core/KeychainAccess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
15 changes: 13 additions & 2 deletions Sources/PokeTokenBar/Core/OAuthLimitsProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -178,6 +187,8 @@ actor OAuthAccessTokenCache {
// → Keychain 읽기는 명시적 사용자 동작(설정/팝오버의 갱신 버튼, allowKeychainPrompt=true)에서만
// 수행한다. 파일이 유효 토큰을 들고 있으면 매 폴이 그걸 쓴다. 파일이 없거나 oauth 가 빠진
// 뒤에만 캐시가 버티고, 그것도 만료되면 한도는 stale 표시 후 사용자가 갱신한다.
// 그 사용자 갱신은 `bypassCache: true` 로 들어온다(#300) — 옛 토큰이 아직 유효해도
// 여기서 캐시를 돌려주면 계정 전환이 버튼으로도 안 끝난다.
// 자동 경로는 여기서 끝난다(키체인 미열람). 파일이 있는데 계정 OAuth 만 없으면 재로그인이
// 답이므로 그때만 안내를 바꾼다 — 판정은 이 분기 안에서 해야 사용자 경로가 파일을 두 번 읽지 않는다.
guard allowKeychainPrompt else {
Expand Down
10 changes: 10 additions & 0 deletions Sources/PokeTokenBar/UI/PopoverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
112 changes: 112 additions & 0 deletions Tests/PokeTokenBarTests/CredentialSwitchCacheTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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() {}
}
24 changes: 24 additions & 0 deletions Tests/PokeTokenBarTests/PopoverNavigationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions docs/reference/defect-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 으로 *모든 항목의 데이터*를 받는 쿼리는 **파라미터 단계에서** 거절된다. 항목이
Expand Down