|
|
@@ -0,0 +1,209 @@
|
|
|
+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()
|
|
|
+ }
|
|
|
+}
|