| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209 |
- 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<TimeInterval> = 30...120
- private let defaults: UserDefaults
- private var isPremium = false
- private var continuousSessionStart: Date?
- private var usageTimerTask: Task<Void, Never>?
- private var reviewDelayTask: Task<Void, Never>?
- private var cancellables = Set<AnyCancellable>()
- 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()
- }
- }
|