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
52 changes: 48 additions & 4 deletions Sources/PokeTokenBar/Core/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,11 @@ final class UsageStore {
private var timer: Timer?
private var networkMonitor: NetworkReachabilityMonitor?
private var pollingSuspended = false // 디스플레이 꺼짐 동안 폴링 정지 (배터리)
/// 정지 중에만 도는 저빈도 점검. screensDidWake 를 놓치면 폴링이 영영 안 돌아와 마지막 스냅샷
/// (자정에 기록된 빈 스냅샷 등)이 재시작 전까지 굳었다(#350).
private var suspendedProbeTimer: Timer?
static let suspendedProbeInterval: TimeInterval = 300
private let displaysAsleep: @MainActor () -> Bool
private var emptyUsageRetryTask: Task<Void, Never>?
/// 한도 알림 상태(엣지 트리거) — 창 이름 → 이미 알린 최고 tier(0=없음, 1=경고, 2=위험).
/// utilization 이 경고선 아래로 내려가면 맵에서 제거해 재무장. resets_at 같은 매 fetch 변하는
Expand Down Expand Up @@ -748,8 +753,10 @@ final class UsageStore {
SessionKeyLimitsProvider(store: .forConfigRoot($0))
},
autoRefresh: Bool = true,
defaults: UserDefaults = .standard) {
defaults: UserDefaults = .standard,
displaysAsleep: @escaping @MainActor () -> Bool = { CGDisplayIsAsleep(CGMainDisplayID()) != 0 }) {
self.providers = providers
self.displaysAsleep = displaysAsleep
self.limitsProvider = claudeLimitsProvider
self.additionalClaudeLimitsProvider = additionalClaudeLimitsProvider
self.discoverClaudeConfigDirs = discoverClaudeConfigDirs
Expand Down Expand Up @@ -806,7 +813,7 @@ final class UsageStore {
NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didWakeNotification, object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor in await self?.refresh() }
Task { @MainActor in await self?.handleSystemWake() }
}
// 디스플레이 꺼짐 → 폴링(로그 파싱 + 한도 조회 + codex 서브프로세스) 일시정지, 켜짐 → 재개 + 즉시 갱신 (배터리)
NSWorkspace.shared.notificationCenter.addObserver(
Expand Down Expand Up @@ -849,19 +856,55 @@ final class UsageStore {
timer = t
}

var isPollingSuspended: Bool { pollingSuspended }
var isPollingTimerScheduled: Bool { timer != nil }
var isSuspendedProbeScheduled: Bool { suspendedProbeTimer != nil }

/// 디스플레이 꺼짐 → 폴링 타이머 정지(예약된 로그 파싱·한도 조회 중단).
private func suspendPolling() {
func suspendPolling() {
pollingSuspended = true
timer?.invalidate()
timer = nil
suspendedProbeTimer?.invalidate()
let probe = Timer(timeInterval: Self.suspendedProbeInterval, repeats: true) { _ in
Task { @MainActor [weak self] in await self?.runSuspendedPollingProbe() }
}
probe.tolerance = Self.suspendedProbeInterval * 0.2
RunLoop.main.add(probe, forMode: .common)
suspendedProbeTimer = probe
}

/// 디스플레이 켜짐 → 폴링 재개 + 즉시 1회 갱신(켜졌을 때 메뉴 숫자 최신화).
private func resumePolling() {
guard pollingSuspended else { return }
clearPollingSuspension()
Task { await refresh() }
}

private func clearPollingSuspension() {
pollingSuspended = false
suspendedProbeTimer?.invalidate()
suspendedProbeTimer = nil
reschedule()
Task { await refresh() }
}

/// screensDidWake 없이 화면이 이미 켜져 있으면 정지를 푼다. 풀었으면 true.
@discardableResult
private func clearStaleSuspension(reason: String) -> Bool {
guard pollingSuspended, !displaysAsleep() else { return false }
AppLog.write("polling resumed without screensDidWake (\(reason))")
clearPollingSuspension()
return true
}

func runSuspendedPollingProbe() async {
guard clearStaleSuspension(reason: "probe") else { return }
await refresh()
}

func handleSystemWake() async {
clearStaleSuspension(reason: "didWake")
await refresh()
}

// MARK: 갱신
Expand All @@ -872,6 +915,7 @@ final class UsageStore {
// Claude 한도가 다음 수동 액션까지 빈 채로 남던 회귀 방지.
if isRefreshing { refreshPending = true; return }
isRefreshing = true
clearStaleSuspension(reason: "refresh")
// App Nap 방지 — 백그라운드 스로틀로 로그 파싱·codex 조회가 타임아웃되는 것을 막는다 (시스템 슬립은 허용)
let activity = ProcessInfo.processInfo.beginActivity(
options: .userInitiatedAllowingIdleSystemSleep, reason: "PokeTokenBar usage refresh")
Expand Down
86 changes: 86 additions & 0 deletions Tests/PokeTokenBarTests/UsageStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ private func sessionOrg(_ id: String, hasUsage: Bool) -> SessionKeyOrganization
limits: claudeLimits(fiveHourUtil: hasUsage ? 7 : 0))
}

/// 디스플레이 슬립 상태 스텁 — 테스트가 "화면은 켜졌는데 screensDidWake 는 안 온" 상황을 만든다.
private final class DisplayState: @unchecked Sendable {
nonisolated(unsafe) var asleep: Bool
init(asleep: Bool) { self.asleep = asleep }
}

private final class FakeStatusProvider: ProviderStatusProviding, @unchecked Sendable {
nonisolated(unsafe) var result: [String: ProviderStatus]
init(_ result: [String: ProviderStatus] = [:]) { self.result = result }
Expand Down Expand Up @@ -233,6 +239,86 @@ final class UsageStoreTests: XCTestCase {
XCTAssertGreaterThanOrEqual(calls, 2, "겹친 refresh 는 완료 후 1회 재실행돼야 한다(드롭 금지)")
}

// MARK: 디스플레이 슬립 폴링 정지 자가 복구 (#350)

private func makeSleepStore(displays: DisplayState,
provider: FakeUsageProvider? = nil) -> UsageStore {
let p = provider ?? FakeUsageProvider(id: "claude_code", displayName: "Claude Code",
daily: todayDaily(1_000))
return UsageStore(providers: [p],
claudeLimitsProvider: FakeClaudeLimits(status: nil),
codexLimitsProvider: FakeCodexLimits(status: nil),
antigravityLimitsProvider: FakeAntigravityLimits(status: nil),
statusProvider: FakeStatusProvider([:]),
autoRefresh: false, defaults: testDefaults,
displaysAsleep: { displays.asleep })
}

/// [회귀 #350] screensDidSleep 뒤 screensDidWake 를 놓치면 폴링 타이머가 영영 안 돌아와, 자정에
/// 기록된 빈 스냅샷(menuTitle "0", providers [])이 재시작 전까지 굳었다. 정지 중에는 저빈도
/// 점검이 돌아 화면이 실제로 켜져 있으면 폴링을 되살려야 한다.
func testSuspendedPollingProbeResumesWhenDisplaysAreAwake() async {
let displays = DisplayState(asleep: true)
let store = makeSleepStore(displays: displays)
store.suspendPolling()
XCTAssertTrue(store.isPollingSuspended)
XCTAssertFalse(store.isPollingTimerScheduled, "정지 중엔 폴링 타이머가 없어야 한다(배터리)")
XCTAssertTrue(store.isSuspendedProbeScheduled, "정지 중엔 복구 점검 타이머가 있어야 한다")

displays.asleep = false // 화면은 켜졌지만 screensDidWake 는 오지 않았다
await store.runSuspendedPollingProbe()

XCTAssertFalse(store.isPollingSuspended, "화면이 켜져 있으면 점검이 폴링을 되살려야 한다")
XCTAssertTrue(store.isPollingTimerScheduled)
XCTAssertFalse(store.isSuspendedProbeScheduled, "복구 뒤 점검 타이머는 정리돼야 한다")
XCTAssertEqual(store.todayTotalTokens, 1_000, "복구 직후 1회 갱신으로 스냅샷이 채워져야 한다")
}

func testSuspendedPollingProbeStaysSuspendedWhileDisplaysSleep() async {
let displays = DisplayState(asleep: true)
let store = makeSleepStore(displays: displays)
store.suspendPolling()
await store.runSuspendedPollingProbe()
XCTAssertTrue(store.isPollingSuspended, "화면이 꺼져 있는 동안은 계속 정지해야 한다")
XCTAssertFalse(store.isPollingTimerScheduled)
XCTAssertTrue(store.isSuspendedProbeScheduled)
}

/// 시스템 wake(didWake)는 오고 screensDidWake 만 빠진 경우 — 점검 주기를 기다리지 않고 바로 복구.
func testSystemWakeResumesSuspendedPollingWhenDisplaysAreAwake() async {
let displays = DisplayState(asleep: true)
let store = makeSleepStore(displays: displays)
store.suspendPolling()
displays.asleep = false
await store.handleSystemWake()
XCTAssertFalse(store.isPollingSuspended)
XCTAssertTrue(store.isPollingTimerScheduled)
XCTAssertEqual(store.todayTotalTokens, 1_000)
}

/// 어떤 경로든(네트워크 재연결·수동 새로고침·날짜 변경) refresh 가 돌 때 화면이 켜져 있으면
/// 정지 상태를 바로잡는다 — 점검·wake 와 무관한 단독 경로.
func testRefreshWhileDisplaysAwakeClearsAStaleSuspension() async {
let displays = DisplayState(asleep: true)
let store = makeSleepStore(displays: displays)
store.suspendPolling()
displays.asleep = false
await store.refresh(scheduleEmptyRetry: false)
XCTAssertFalse(store.isPollingSuspended)
XCTAssertTrue(store.isPollingTimerScheduled)
XCTAssertFalse(store.isSuspendedProbeScheduled)
}

/// 화면이 꺼진 채 도는 refresh(자정 NSCalendarDayChanged 등)는 정지를 풀지 않는다.
func testRefreshWhileDisplaysAsleepKeepsPollingSuspended() async {
let displays = DisplayState(asleep: true)
let store = makeSleepStore(displays: displays)
store.suspendPolling()
await store.refresh(scheduleEmptyRetry: false)
XCTAssertTrue(store.isPollingSuspended)
XCTAssertFalse(store.isPollingTimerScheduled)
}

// MARK: Keychain 프롬프트 경로 분리 (회귀)

/// 회귀 가드: 자동 폴링은 프롬프트 없는 경로(allowKeychainPrompt=false)로만 한도를 조회하고,
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 @@ -777,6 +777,14 @@ read_when:

## 에너지 (상시 표시 애니메이션)

- **절전용 정지 상태는 짝 알림 하나에만 복구를 맡기지 마라.** `screensDidSleep` 이 폴링 타이머를 끄고
`screensDidWake` 만 되살렸다. wake 알림을 놓치면 폴링이 영영 멈추고, 자정 `NSCalendarDayChanged` 갱신이
남긴 빈 스냅샷(menuTitle "0", providers [])이 재시작 전까지 굳었다(#350, `lastError` 도 비어 무증상).
**왜 못 걸렀나:** 정지/재개 경로에 테스트가 하나도 없었고, 실기기에서는 알림이 대개 짝지어 와서 재현이
안 됐다. → 정지 중엔 5분 점검 타이머가 실제 디스플레이 상태(`CGDisplayIsAsleep`)를 보고 복구하고,
`didWake`·모든 `refresh()` 진입도 같은 검사를 한다. 회귀 가드: `UsageStoreTests` 의
`testSuspendedPollingProbe*`·`testSystemWakeResumes*`·`testRefreshWhileDisplays*`.

- **메뉴바 상태아이템 = idle CPU 저격수 (두 규칙 필수).** 실측: 라이브 앱 idle ~14% CPU → 수정 후 ~2%.
① **`statusItem.button.image` 대입은 반드시 `setDisableActions` 트랜잭션 안에서** (`AppDelegate.setStatusImage`).
레이어 백드 `NSStatusBarButton` 은 이미지 대입마다 `NSStatusItemScene` 암묵적 전환 애니메이션
Expand Down
Loading