|
|
@@ -0,0 +1,341 @@
|
|
|
+import Cocoa
|
|
|
+import StoreKit
|
|
|
+
|
|
|
+@MainActor
|
|
|
+final class AppRatingManager {
|
|
|
+ static let shared = AppRatingManager()
|
|
|
+
|
|
|
+ private enum Constants {
|
|
|
+ static let usageThresholdSeconds: TimeInterval = 15 * 60
|
|
|
+ static let usageCheckInterval: TimeInterval = 30
|
|
|
+ static let promptPresentationDelay: TimeInterval = 1.5
|
|
|
+ }
|
|
|
+
|
|
|
+ private enum PromptSource {
|
|
|
+ case firstPurchase
|
|
|
+ case usageMilestone
|
|
|
+
|
|
|
+ var title: String {
|
|
|
+ switch self {
|
|
|
+ case .firstPurchase:
|
|
|
+ "Enjoying Smart Printer?"
|
|
|
+ case .usageMilestone:
|
|
|
+ "Enjoying Smart Printer?"
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ var message: String {
|
|
|
+ switch self {
|
|
|
+ case .firstPurchase:
|
|
|
+ "Thanks for going Premium! Do you like the app?"
|
|
|
+ case .usageMilestone:
|
|
|
+ "You've been using Smart Printer for a while. Would you mind rating us on the App Store?"
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private struct State {
|
|
|
+ var firstPurchasePromptPending = false
|
|
|
+ var firstPurchasePromptCompleted = false
|
|
|
+ var usageBasedPromptCompleted = false
|
|
|
+ var hasRequestedReview = false
|
|
|
+ var accumulatedActiveUsageSeconds: TimeInterval = 0
|
|
|
+
|
|
|
+ static func load() -> State {
|
|
|
+ let defaults = UserDefaults.standard
|
|
|
+ var state = State()
|
|
|
+
|
|
|
+ if defaults.object(forKey: Key.accumulatedActiveUsageSeconds) != nil {
|
|
|
+ state.accumulatedActiveUsageSeconds = defaults.double(forKey: Key.accumulatedActiveUsageSeconds)
|
|
|
+ }
|
|
|
+
|
|
|
+ state.firstPurchasePromptPending = defaults.bool(forKey: Key.firstPurchasePromptPending)
|
|
|
+ state.firstPurchasePromptCompleted = defaults.bool(forKey: Key.firstPurchasePromptCompleted)
|
|
|
+ state.usageBasedPromptCompleted = defaults.bool(forKey: Key.usageBasedPromptCompleted)
|
|
|
+ state.hasRequestedReview = defaults.bool(forKey: Key.hasRequestedReview)
|
|
|
+
|
|
|
+ if defaults.bool(forKey: Key.legacyHasPromptedAfterFirstPurchase) {
|
|
|
+ state.firstPurchasePromptCompleted = true
|
|
|
+ defaults.removeObject(forKey: Key.legacyHasPromptedAfterFirstPurchase)
|
|
|
+ }
|
|
|
+
|
|
|
+ defaults.removeObject(forKey: Key.legacyPromptedTransactionIDs)
|
|
|
+ return state
|
|
|
+ }
|
|
|
+
|
|
|
+ func save() {
|
|
|
+ let defaults = UserDefaults.standard
|
|
|
+ defaults.set(firstPurchasePromptPending, forKey: Key.firstPurchasePromptPending)
|
|
|
+ defaults.set(firstPurchasePromptCompleted, forKey: Key.firstPurchasePromptCompleted)
|
|
|
+ defaults.set(usageBasedPromptCompleted, forKey: Key.usageBasedPromptCompleted)
|
|
|
+ defaults.set(hasRequestedReview, forKey: Key.hasRequestedReview)
|
|
|
+ defaults.set(accumulatedActiveUsageSeconds, forKey: Key.accumulatedActiveUsageSeconds)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private enum Key {
|
|
|
+ static let firstPurchasePromptPending = "appRating.firstPurchasePromptPending"
|
|
|
+ static let firstPurchasePromptCompleted = "appRating.firstPurchasePromptCompleted"
|
|
|
+ static let usageBasedPromptCompleted = "appRating.usageBasedPromptCompleted"
|
|
|
+ static let hasRequestedReview = "appRating.hasRequestedReview"
|
|
|
+ static let accumulatedActiveUsageSeconds = "appRating.accumulatedActiveUsageSeconds"
|
|
|
+ static let legacyHasPromptedAfterFirstPurchase = "appRating.hasPromptedAfterFirstPurchase"
|
|
|
+ static let legacyPromptedTransactionIDs = "appRating.promptedTransactionIDs"
|
|
|
+ }
|
|
|
+
|
|
|
+ private var state = State.load()
|
|
|
+ private var activeSessionStart: Date?
|
|
|
+ private var usageCheckTimer: Timer?
|
|
|
+ private var isShowingPrompt = false
|
|
|
+ private var pendingPromptWorkItem: DispatchWorkItem?
|
|
|
+ private var observers: [NSObjectProtocol] = []
|
|
|
+ private var hasStarted = false
|
|
|
+
|
|
|
+ private init() {}
|
|
|
+
|
|
|
+ func start() {
|
|
|
+ guard !hasStarted else { return }
|
|
|
+ hasStarted = true
|
|
|
+
|
|
|
+ let center = NotificationCenter.default
|
|
|
+ observers.append(
|
|
|
+ center.addObserver(
|
|
|
+ forName: NSApplication.didBecomeActiveNotification,
|
|
|
+ object: nil,
|
|
|
+ queue: .main
|
|
|
+ ) { [weak self] _ in
|
|
|
+ Task { @MainActor in self?.applicationDidBecomeActive() }
|
|
|
+ }
|
|
|
+ )
|
|
|
+ observers.append(
|
|
|
+ center.addObserver(
|
|
|
+ forName: NSApplication.willResignActiveNotification,
|
|
|
+ object: nil,
|
|
|
+ queue: .main
|
|
|
+ ) { [weak self] _ in
|
|
|
+ Task { @MainActor in self?.applicationWillResignActive() }
|
|
|
+ }
|
|
|
+ )
|
|
|
+ observers.append(
|
|
|
+ center.addObserver(
|
|
|
+ forName: .premiumStatusDidChange,
|
|
|
+ object: nil,
|
|
|
+ queue: .main
|
|
|
+ ) { [weak self] _ in
|
|
|
+ Task { @MainActor in self?.premiumStatusDidChange() }
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+ if NSApp.isActive {
|
|
|
+ applicationDidBecomeActive()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Asks premium users on their first purchase whether they like the app.
|
|
|
+ func promptAfterFirstPurchaseIfNeeded(on window: NSWindow?) {
|
|
|
+ guard isEligibleForAnyPrompt else { return }
|
|
|
+ guard canShowFirstPurchasePrompt else { return }
|
|
|
+
|
|
|
+ state.firstPurchasePromptPending = true
|
|
|
+ state.save()
|
|
|
+ presentPrompt(source: .firstPurchase, on: window ?? presentationWindow())
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - App lifecycle
|
|
|
+
|
|
|
+ private func applicationDidBecomeActive() {
|
|
|
+ resumeUsageTrackingIfNeeded()
|
|
|
+ scheduleDeferredPromptChecks()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func applicationWillResignActive() {
|
|
|
+ commitActiveUsageSession()
|
|
|
+ stopUsageCheckTimer()
|
|
|
+ pendingPromptWorkItem?.cancel()
|
|
|
+ pendingPromptWorkItem = nil
|
|
|
+ }
|
|
|
+
|
|
|
+ private func premiumStatusDidChange() {
|
|
|
+ if StoreManager.shared.isPremium {
|
|
|
+ resumeUsageTrackingIfNeeded()
|
|
|
+ scheduleDeferredPromptChecks()
|
|
|
+ } else {
|
|
|
+ commitActiveUsageSession()
|
|
|
+ stopUsageCheckTimer()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Eligibility
|
|
|
+
|
|
|
+ private var isEligibleForAnyPrompt: Bool {
|
|
|
+ StoreManager.shared.isPremium && !state.hasRequestedReview
|
|
|
+ }
|
|
|
+
|
|
|
+ private var canShowFirstPurchasePrompt: Bool {
|
|
|
+ isEligibleForAnyPrompt && !state.firstPurchasePromptCompleted
|
|
|
+ }
|
|
|
+
|
|
|
+ private var canShowUsageBasedPrompt: Bool {
|
|
|
+ isEligibleForAnyPrompt
|
|
|
+ && state.firstPurchasePromptCompleted
|
|
|
+ && !state.usageBasedPromptCompleted
|
|
|
+ && currentAccumulatedUsageSeconds >= Constants.usageThresholdSeconds
|
|
|
+ }
|
|
|
+
|
|
|
+ private var shouldTrackUsage: Bool {
|
|
|
+ isEligibleForAnyPrompt
|
|
|
+ && state.firstPurchasePromptCompleted
|
|
|
+ && !state.usageBasedPromptCompleted
|
|
|
+ }
|
|
|
+
|
|
|
+ private var currentAccumulatedUsageSeconds: TimeInterval {
|
|
|
+ var total = state.accumulatedActiveUsageSeconds
|
|
|
+ if let activeSessionStart {
|
|
|
+ total += Date().timeIntervalSince(activeSessionStart)
|
|
|
+ }
|
|
|
+ return total
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Usage tracking
|
|
|
+
|
|
|
+ private func resumeUsageTrackingIfNeeded() {
|
|
|
+ guard shouldTrackUsage else {
|
|
|
+ commitActiveUsageSession()
|
|
|
+ stopUsageCheckTimer()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if activeSessionStart == nil {
|
|
|
+ activeSessionStart = Date()
|
|
|
+ }
|
|
|
+ startUsageCheckTimer()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func commitActiveUsageSession() {
|
|
|
+ guard let sessionStart = activeSessionStart else { return }
|
|
|
+ let elapsed = max(0, Date().timeIntervalSince(sessionStart))
|
|
|
+ activeSessionStart = nil
|
|
|
+ guard elapsed > 0 else { return }
|
|
|
+
|
|
|
+ state.accumulatedActiveUsageSeconds += elapsed
|
|
|
+ state.save()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func startUsageCheckTimer() {
|
|
|
+ guard usageCheckTimer == nil else { return }
|
|
|
+
|
|
|
+ let timer = Timer(timeInterval: Constants.usageCheckInterval, repeats: true) { [weak self] _ in
|
|
|
+ Task { @MainActor in
|
|
|
+ self?.evaluateUsageMilestonePrompt()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ RunLoop.main.add(timer, forMode: .common)
|
|
|
+ usageCheckTimer = timer
|
|
|
+ }
|
|
|
+
|
|
|
+ private func stopUsageCheckTimer() {
|
|
|
+ usageCheckTimer?.invalidate()
|
|
|
+ usageCheckTimer = nil
|
|
|
+ }
|
|
|
+
|
|
|
+ private func evaluateUsageMilestonePrompt() {
|
|
|
+ guard shouldTrackUsage else {
|
|
|
+ stopUsageCheckTimer()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ guard currentAccumulatedUsageSeconds >= Constants.usageThresholdSeconds else { return }
|
|
|
+ presentPrompt(source: .usageMilestone, on: presentationWindow())
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Prompt presentation
|
|
|
+
|
|
|
+ private func scheduleDeferredPromptChecks() {
|
|
|
+ pendingPromptWorkItem?.cancel()
|
|
|
+
|
|
|
+ let workItem = DispatchWorkItem { [weak self] in
|
|
|
+ guard let self else { return }
|
|
|
+ if self.canShowFirstPurchasePrompt, self.state.firstPurchasePromptPending {
|
|
|
+ self.presentPrompt(source: .firstPurchase, on: self.presentationWindow())
|
|
|
+ return
|
|
|
+ }
|
|
|
+ self.evaluateUsageMilestonePrompt()
|
|
|
+ }
|
|
|
+
|
|
|
+ pendingPromptWorkItem = workItem
|
|
|
+ DispatchQueue.main.asyncAfter(deadline: .now() + Constants.promptPresentationDelay, execute: workItem)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func presentPrompt(source: PromptSource, on window: NSWindow?) {
|
|
|
+ guard !isShowingPrompt else { return }
|
|
|
+
|
|
|
+ switch source {
|
|
|
+ case .firstPurchase:
|
|
|
+ guard canShowFirstPurchasePrompt else { return }
|
|
|
+ case .usageMilestone:
|
|
|
+ guard canShowUsageBasedPrompt else { return }
|
|
|
+ commitActiveUsageSession()
|
|
|
+ stopUsageCheckTimer()
|
|
|
+ }
|
|
|
+
|
|
|
+ isShowingPrompt = true
|
|
|
+
|
|
|
+ let alert = NSAlert()
|
|
|
+ alert.messageText = source.title
|
|
|
+ alert.informativeText = source.message
|
|
|
+ alert.alertStyle = .informational
|
|
|
+ alert.addButton(withTitle: "Yes")
|
|
|
+ alert.addButton(withTitle: "Not Really")
|
|
|
+
|
|
|
+ let handleResponse: (NSApplication.ModalResponse) -> Void = { [weak self] response in
|
|
|
+ self?.handlePromptResponse(source: source, response: response, window: window)
|
|
|
+ }
|
|
|
+
|
|
|
+ if let window {
|
|
|
+ alert.beginSheetModal(for: window) { response in
|
|
|
+ handleResponse(response)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ handleResponse(alert.runModal())
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func handlePromptResponse(
|
|
|
+ source: PromptSource,
|
|
|
+ response: NSApplication.ModalResponse,
|
|
|
+ window: NSWindow?
|
|
|
+ ) {
|
|
|
+ isShowingPrompt = false
|
|
|
+
|
|
|
+ switch source {
|
|
|
+ case .firstPurchase:
|
|
|
+ state.firstPurchasePromptPending = false
|
|
|
+ state.firstPurchasePromptCompleted = true
|
|
|
+ case .usageMilestone:
|
|
|
+ state.usageBasedPromptCompleted = true
|
|
|
+ stopUsageCheckTimer()
|
|
|
+ }
|
|
|
+
|
|
|
+ if response == .alertFirstButtonReturn {
|
|
|
+ state.hasRequestedReview = true
|
|
|
+ state.save()
|
|
|
+ requestAppStoreReview(from: window)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ state.save()
|
|
|
+ resumeUsageTrackingIfNeeded()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func requestAppStoreReview(from window: NSWindow?) {
|
|
|
+ let viewController = window?.contentViewController
|
|
|
+ ?? presentationWindow()?.contentViewController
|
|
|
+ ?? NSApp.keyWindow?.contentViewController
|
|
|
+ guard let viewController else { return }
|
|
|
+ AppStore.requestReview(in: viewController)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func presentationWindow() -> NSWindow? {
|
|
|
+ NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first(where: \.canBecomeMain)
|
|
|
+ }
|
|
|
+}
|