AppRatingManager.swift 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. import AppKit
  2. import Combine
  3. import Foundation
  4. import StoreKit
  5. /// Schedules the system App Store review prompt after meaningful engagement.
  6. ///
  7. /// Apple guidelines followed:
  8. /// - Uses `SKStoreReviewController` only (no custom pre-prompts or buttons).
  9. /// - Never requests immediately after purchase.
  10. /// - Waits for sustained usage, then applies a random delay before presenting.
  11. /// - Respects minimum spacing and yearly request limits on our side.
  12. @MainActor
  13. final class AppRatingManager: ObservableObject {
  14. private enum Keys {
  15. static let sessionCount = "appRating.sessionCount"
  16. static let reviewRequestTimestamps = "appRating.reviewRequestTimestamps"
  17. }
  18. private static let continuousUsageThreshold: TimeInterval = 25 * 60
  19. private static let minimumDaysBetweenRequests = 3
  20. private static let maxReviewRequestsPerYear = 3
  21. private static let minimumSessionsBeforeEligible = 2
  22. private static let reviewDelayRange: ClosedRange<TimeInterval> = 30...120
  23. private let defaults: UserDefaults
  24. private var isPremium = false
  25. private var continuousSessionStart: Date?
  26. private var usageTimerTask: Task<Void, Never>?
  27. private var reviewDelayTask: Task<Void, Never>?
  28. private var cancellables = Set<AnyCancellable>()
  29. private var pendingReviewAfterDelay = false
  30. init(defaults: UserDefaults = .standard) {
  31. self.defaults = defaults
  32. incrementSessionCount()
  33. observeAppLifecycle()
  34. }
  35. func updatePremiumStatus(_ isPremium: Bool) {
  36. self.isPremium = isPremium
  37. refreshUsageTracking()
  38. }
  39. private func incrementSessionCount() {
  40. let count = defaults.integer(forKey: Keys.sessionCount)
  41. defaults.set(count + 1, forKey: Keys.sessionCount)
  42. }
  43. private var sessionCount: Int {
  44. defaults.integer(forKey: Keys.sessionCount)
  45. }
  46. private var reviewRequestTimestamps: [TimeInterval] {
  47. get {
  48. defaults.array(forKey: Keys.reviewRequestTimestamps) as? [TimeInterval] ?? []
  49. }
  50. set {
  51. defaults.set(newValue, forKey: Keys.reviewRequestTimestamps)
  52. }
  53. }
  54. private var isEligibleForReviewRequest: Bool {
  55. guard sessionCount >= Self.minimumSessionsBeforeEligible else { return false }
  56. guard daysSinceLastReviewRequest >= Self.minimumDaysBetweenRequests else { return false }
  57. guard reviewRequestsInPastYear < Self.maxReviewRequestsPerYear else { return false }
  58. return true
  59. }
  60. private var isEligibleForEngagementTracking: Bool {
  61. isPremium && isEligibleForReviewRequest && !pendingReviewAfterDelay
  62. }
  63. private var daysSinceLastReviewRequest: Int {
  64. guard let lastRequest = reviewRequestTimestamps.max() else { return Int.max }
  65. let elapsed = Date().timeIntervalSince(Date(timeIntervalSince1970: lastRequest))
  66. return Int(elapsed / 86_400)
  67. }
  68. private var reviewRequestsInPastYear: Int {
  69. let oneYearAgo = Date().timeIntervalSince1970 - (365 * 86_400)
  70. return reviewRequestTimestamps.filter { $0 >= oneYearAgo }.count
  71. }
  72. private func observeAppLifecycle() {
  73. NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)
  74. .sink { [weak self] _ in
  75. self?.appDidBecomeActive()
  76. }
  77. .store(in: &cancellables)
  78. NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification)
  79. .sink { [weak self] _ in
  80. self?.appDidResignActive()
  81. }
  82. .store(in: &cancellables)
  83. }
  84. private func appDidBecomeActive() {
  85. refreshUsageTracking()
  86. }
  87. private func appDidResignActive() {
  88. pauseUsageTracking()
  89. cancelPendingReviewRequest()
  90. }
  91. private func refreshUsageTracking() {
  92. guard isEligibleForEngagementTracking else {
  93. pauseUsageTracking()
  94. return
  95. }
  96. guard NSApplication.shared.isActive else { return }
  97. beginContinuousUsageSession()
  98. }
  99. private func beginContinuousUsageSession() {
  100. guard isEligibleForEngagementTracking else { return }
  101. guard continuousSessionStart == nil else { return }
  102. continuousSessionStart = Date()
  103. startUsageTimer()
  104. }
  105. private func pauseUsageTracking() {
  106. continuousSessionStart = nil
  107. usageTimerTask?.cancel()
  108. usageTimerTask = nil
  109. }
  110. private func startUsageTimer() {
  111. usageTimerTask?.cancel()
  112. usageTimerTask = Task { [weak self] in
  113. while !Task.isCancelled {
  114. try? await Task.sleep(nanoseconds: 1_000_000_000)
  115. guard !Task.isCancelled else { break }
  116. self?.checkContinuousUsageThreshold()
  117. }
  118. }
  119. }
  120. private func checkContinuousUsageThreshold() {
  121. guard
  122. isEligibleForEngagementTracking,
  123. let sessionStart = continuousSessionStart
  124. else {
  125. return
  126. }
  127. let elapsed = Date().timeIntervalSince(sessionStart)
  128. guard elapsed >= Self.continuousUsageThreshold else { return }
  129. scheduleReviewRequestWithRandomDelay()
  130. }
  131. private func scheduleReviewRequestWithRandomDelay() {
  132. guard isPremium, isEligibleForReviewRequest, !pendingReviewAfterDelay else { return }
  133. pendingReviewAfterDelay = true
  134. pauseUsageTracking()
  135. let delay = Double.random(in: Self.reviewDelayRange)
  136. reviewDelayTask?.cancel()
  137. reviewDelayTask = Task { [weak self] in
  138. try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
  139. guard !Task.isCancelled else { return }
  140. await MainActor.run {
  141. self?.presentReviewRequestIfAppropriate()
  142. }
  143. }
  144. }
  145. private func cancelPendingReviewRequest() {
  146. guard pendingReviewAfterDelay else { return }
  147. reviewDelayTask?.cancel()
  148. reviewDelayTask = nil
  149. pendingReviewAfterDelay = false
  150. }
  151. private func presentReviewRequestIfAppropriate() {
  152. pendingReviewAfterDelay = false
  153. reviewDelayTask = nil
  154. guard isPremium, isEligibleForReviewRequest else { return }
  155. guard NSApplication.shared.isActive else { return }
  156. recordReviewRequestAttempt()
  157. requestAppStoreReview()
  158. }
  159. private func recordReviewRequestAttempt() {
  160. var timestamps = reviewRequestTimestamps
  161. timestamps.append(Date().timeIntervalSince1970)
  162. let oneYearAgo = Date().timeIntervalSince1970 - (365 * 86_400)
  163. reviewRequestTimestamps = timestamps.filter { $0 >= oneYearAgo }
  164. }
  165. private func requestAppStoreReview() {
  166. SKStoreReviewController.requestReview()
  167. }
  168. /// User-initiated review from Settings. Does not affect automatic prompt scheduling.
  169. func requestReviewFromSettings() {
  170. SKStoreReviewController.requestReview()
  171. }
  172. }