import AppKit import Combine import Foundation import StoreKit /// Schedules the system App Store review prompt after meaningful engagement. /// /// Apple guidelines followed: /// - Uses `SKStoreReviewController` only (no custom pre-prompts or buttons). /// - Never requests immediately after purchase. /// - Waits for sustained usage, then applies a random delay before presenting. /// - Respects minimum spacing and yearly request limits on our side. @MainActor final class AppRatingManager: ObservableObject { private enum Keys { static let sessionCount = "appRating.sessionCount" static let reviewRequestTimestamps = "appRating.reviewRequestTimestamps" } private static let continuousUsageThreshold: TimeInterval = 25 * 60 private static let minimumDaysBetweenRequests = 3 private static let maxReviewRequestsPerYear = 3 private static let minimumSessionsBeforeEligible = 2 private static let reviewDelayRange: ClosedRange = 30...120 private let defaults: UserDefaults private var isPremium = false private var continuousSessionStart: Date? private var usageTimerTask: Task? private var reviewDelayTask: Task? private var cancellables = Set() private var pendingReviewAfterDelay = false init(defaults: UserDefaults = .standard) { self.defaults = defaults incrementSessionCount() observeAppLifecycle() } func updatePremiumStatus(_ isPremium: Bool) { self.isPremium = isPremium refreshUsageTracking() } private func incrementSessionCount() { let count = defaults.integer(forKey: Keys.sessionCount) defaults.set(count + 1, forKey: Keys.sessionCount) } private var sessionCount: Int { defaults.integer(forKey: Keys.sessionCount) } private var reviewRequestTimestamps: [TimeInterval] { get { defaults.array(forKey: Keys.reviewRequestTimestamps) as? [TimeInterval] ?? [] } set { defaults.set(newValue, forKey: Keys.reviewRequestTimestamps) } } private var isEligibleForReviewRequest: Bool { guard sessionCount >= Self.minimumSessionsBeforeEligible else { return false } guard daysSinceLastReviewRequest >= Self.minimumDaysBetweenRequests else { return false } guard reviewRequestsInPastYear < Self.maxReviewRequestsPerYear else { return false } return true } private var isEligibleForEngagementTracking: Bool { isPremium && isEligibleForReviewRequest && !pendingReviewAfterDelay } private var daysSinceLastReviewRequest: Int { guard let lastRequest = reviewRequestTimestamps.max() else { return Int.max } let elapsed = Date().timeIntervalSince(Date(timeIntervalSince1970: lastRequest)) return Int(elapsed / 86_400) } private var reviewRequestsInPastYear: Int { let oneYearAgo = Date().timeIntervalSince1970 - (365 * 86_400) return reviewRequestTimestamps.filter { $0 >= oneYearAgo }.count } private func observeAppLifecycle() { NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification) .sink { [weak self] _ in self?.appDidBecomeActive() } .store(in: &cancellables) NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification) .sink { [weak self] _ in self?.appDidResignActive() } .store(in: &cancellables) } private func appDidBecomeActive() { refreshUsageTracking() } private func appDidResignActive() { pauseUsageTracking() cancelPendingReviewRequest() } private func refreshUsageTracking() { guard isEligibleForEngagementTracking else { pauseUsageTracking() return } guard NSApplication.shared.isActive else { return } beginContinuousUsageSession() } private func beginContinuousUsageSession() { guard isEligibleForEngagementTracking else { return } guard continuousSessionStart == nil else { return } continuousSessionStart = Date() startUsageTimer() } private func pauseUsageTracking() { continuousSessionStart = nil usageTimerTask?.cancel() usageTimerTask = nil } private func startUsageTimer() { usageTimerTask?.cancel() usageTimerTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(nanoseconds: 1_000_000_000) guard !Task.isCancelled else { break } self?.checkContinuousUsageThreshold() } } } private func checkContinuousUsageThreshold() { guard isEligibleForEngagementTracking, let sessionStart = continuousSessionStart else { return } let elapsed = Date().timeIntervalSince(sessionStart) guard elapsed >= Self.continuousUsageThreshold else { return } scheduleReviewRequestWithRandomDelay() } private func scheduleReviewRequestWithRandomDelay() { guard isPremium, isEligibleForReviewRequest, !pendingReviewAfterDelay else { return } pendingReviewAfterDelay = true pauseUsageTracking() let delay = Double.random(in: Self.reviewDelayRange) reviewDelayTask?.cancel() reviewDelayTask = Task { [weak self] in try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) guard !Task.isCancelled else { return } await MainActor.run { self?.presentReviewRequestIfAppropriate() } } } private func cancelPendingReviewRequest() { guard pendingReviewAfterDelay else { return } reviewDelayTask?.cancel() reviewDelayTask = nil pendingReviewAfterDelay = false } private func presentReviewRequestIfAppropriate() { pendingReviewAfterDelay = false reviewDelayTask = nil guard isPremium, isEligibleForReviewRequest else { return } guard NSApplication.shared.isActive else { return } recordReviewRequestAttempt() requestAppStoreReview() } private func recordReviewRequestAttempt() { var timestamps = reviewRequestTimestamps timestamps.append(Date().timeIntervalSince1970) let oneYearAgo = Date().timeIntervalSince1970 - (365 * 86_400) reviewRequestTimestamps = timestamps.filter { $0 >= oneYearAgo } } private func requestAppStoreReview() { SKStoreReviewController.requestReview() } /// User-initiated review from Settings. Does not affect automatic prompt scheduling. func requestReviewFromSettings() { SKStoreReviewController.requestReview() } }