|
@@ -6,90 +6,80 @@ final class AppRatingManager {
|
|
|
static let shared = AppRatingManager()
|
|
static let shared = AppRatingManager()
|
|
|
|
|
|
|
|
private enum Constants {
|
|
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?"
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ static let minLaunches = 5
|
|
|
|
|
+ static let minDaysSinceInstall: TimeInterval = 3 * 24 * 60 * 60
|
|
|
|
|
+ static let printThresholdRange = 3...8
|
|
|
|
|
+ static let cooldownBetweenPrompts: TimeInterval = 120 * 24 * 60 * 60
|
|
|
|
|
+ static let automaticPromptProbability = 0.30
|
|
|
|
|
+ static let reviewPresentationDelay: TimeInterval = 2.0
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private struct State {
|
|
private struct State {
|
|
|
- var firstPurchasePromptPending = false
|
|
|
|
|
- var firstPurchasePromptCompleted = false
|
|
|
|
|
- var usageBasedPromptCompleted = false
|
|
|
|
|
- var hasRequestedReview = false
|
|
|
|
|
- var accumulatedActiveUsageSeconds: TimeInterval = 0
|
|
|
|
|
|
|
+ var launchCount = 0
|
|
|
|
|
+ var successfulPrintCount = 0
|
|
|
|
|
+ var firstLaunchDate: Date?
|
|
|
|
|
+ var lastAutomaticPromptDate: Date?
|
|
|
|
|
+ var randomPrintThreshold: Int?
|
|
|
|
|
|
|
|
static func load() -> State {
|
|
static func load() -> State {
|
|
|
let defaults = UserDefaults.standard
|
|
let defaults = UserDefaults.standard
|
|
|
var state = State()
|
|
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)
|
|
|
|
|
|
|
+ state.launchCount = defaults.integer(forKey: Key.launchCount)
|
|
|
|
|
+ state.successfulPrintCount = defaults.integer(forKey: Key.successfulPrintCount)
|
|
|
|
|
+ state.randomPrintThreshold = defaults.object(forKey: Key.randomPrintThreshold) as? Int
|
|
|
|
|
|
|
|
- if defaults.bool(forKey: Key.legacyHasPromptedAfterFirstPurchase) {
|
|
|
|
|
- state.firstPurchasePromptCompleted = true
|
|
|
|
|
- defaults.removeObject(forKey: Key.legacyHasPromptedAfterFirstPurchase)
|
|
|
|
|
|
|
+ if let timestamp = defaults.object(forKey: Key.firstLaunchDate) as? TimeInterval {
|
|
|
|
|
+ state.firstLaunchDate = Date(timeIntervalSince1970: timestamp)
|
|
|
|
|
+ }
|
|
|
|
|
+ if let timestamp = defaults.object(forKey: Key.lastAutomaticPromptDate) as? TimeInterval {
|
|
|
|
|
+ state.lastAutomaticPromptDate = Date(timeIntervalSince1970: timestamp)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- defaults.removeObject(forKey: Key.legacyPromptedTransactionIDs)
|
|
|
|
|
|
|
+ clearLegacyKeys(in: defaults)
|
|
|
return state
|
|
return state
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func save() {
|
|
func save() {
|
|
|
let defaults = UserDefaults.standard
|
|
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)
|
|
|
|
|
|
|
+ defaults.set(launchCount, forKey: Key.launchCount)
|
|
|
|
|
+ defaults.set(successfulPrintCount, forKey: Key.successfulPrintCount)
|
|
|
|
|
+ defaults.set(randomPrintThreshold, forKey: Key.randomPrintThreshold)
|
|
|
|
|
+
|
|
|
|
|
+ if let firstLaunchDate {
|
|
|
|
|
+ defaults.set(firstLaunchDate.timeIntervalSince1970, forKey: Key.firstLaunchDate)
|
|
|
|
|
+ }
|
|
|
|
|
+ if let lastAutomaticPromptDate {
|
|
|
|
|
+ defaults.set(lastAutomaticPromptDate.timeIntervalSince1970, forKey: Key.lastAutomaticPromptDate)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func clearLegacyKeys(in defaults: UserDefaults) {
|
|
|
|
|
+ let legacyKeys = [
|
|
|
|
|
+ "appRating.firstPurchasePromptPending",
|
|
|
|
|
+ "appRating.firstPurchasePromptCompleted",
|
|
|
|
|
+ "appRating.usageBasedPromptCompleted",
|
|
|
|
|
+ "appRating.hasRequestedReview",
|
|
|
|
|
+ "appRating.accumulatedActiveUsageSeconds",
|
|
|
|
|
+ "appRating.hasPromptedAfterFirstPurchase",
|
|
|
|
|
+ "appRating.promptedTransactionIDs",
|
|
|
|
|
+ ]
|
|
|
|
|
+ legacyKeys.forEach { defaults.removeObject(forKey: $0) }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private enum Key {
|
|
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"
|
|
|
|
|
|
|
+ static let launchCount = "appRating.launchCount"
|
|
|
|
|
+ static let successfulPrintCount = "appRating.successfulPrintCount"
|
|
|
|
|
+ static let firstLaunchDate = "appRating.firstLaunchDate"
|
|
|
|
|
+ static let lastAutomaticPromptDate = "appRating.lastAutomaticPromptDate"
|
|
|
|
|
+ static let randomPrintThreshold = "appRating.randomPrintThreshold"
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private var state = State.load()
|
|
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 observers: [NSObjectProtocol] = []
|
|
|
private var hasStarted = false
|
|
private var hasStarted = false
|
|
|
|
|
+ private var pendingReviewWorkItem: DispatchWorkItem?
|
|
|
|
|
|
|
|
private init() {}
|
|
private init() {}
|
|
|
|
|
|
|
@@ -97,237 +87,90 @@ final class AppRatingManager {
|
|
|
guard !hasStarted else { return }
|
|
guard !hasStarted else { return }
|
|
|
hasStarted = true
|
|
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)
|
|
|
|
|
|
|
+ if state.firstLaunchDate == nil {
|
|
|
|
|
+ state.firstLaunchDate = Date()
|
|
|
}
|
|
}
|
|
|
- return total
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // MARK: - Usage tracking
|
|
|
|
|
-
|
|
|
|
|
- private func resumeUsageTrackingIfNeeded() {
|
|
|
|
|
- guard shouldTrackUsage else {
|
|
|
|
|
- commitActiveUsageSession()
|
|
|
|
|
- stopUsageCheckTimer()
|
|
|
|
|
- return
|
|
|
|
|
|
|
+ if state.randomPrintThreshold == nil {
|
|
|
|
|
+ state.randomPrintThreshold = Int.random(in: Constants.printThresholdRange)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if activeSessionStart == nil {
|
|
|
|
|
- activeSessionStart = Date()
|
|
|
|
|
|
|
+ if isPremiumUser {
|
|
|
|
|
+ state.launchCount += 1
|
|
|
}
|
|
}
|
|
|
- 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()
|
|
state.save()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private func startUsageCheckTimer() {
|
|
|
|
|
- guard usageCheckTimer == nil else { return }
|
|
|
|
|
|
|
+ /// Called after the user successfully sends a print job — a natural positive moment.
|
|
|
|
|
+ func recordSuccessfulPrint(from window: NSWindow?) {
|
|
|
|
|
+ guard isPremiumUser 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
|
|
|
|
|
|
|
+ state.successfulPrintCount += 1
|
|
|
|
|
+ state.save()
|
|
|
|
|
+ considerAutomaticReview(from: window)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private func stopUsageCheckTimer() {
|
|
|
|
|
- usageCheckTimer?.invalidate()
|
|
|
|
|
- usageCheckTimer = nil
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ /// User-initiated rating from Settings. Premium users only.
|
|
|
|
|
+ func requestReviewFromUser(on window: NSWindow?) {
|
|
|
|
|
+ guard isPremiumUser else { return }
|
|
|
|
|
|
|
|
- private func evaluateUsageMilestonePrompt() {
|
|
|
|
|
- guard shouldTrackUsage else {
|
|
|
|
|
- stopUsageCheckTimer()
|
|
|
|
|
|
|
+ if let reviewURL = AppStoreConfig.appStoreReviewURL {
|
|
|
|
|
+ NSWorkspace.shared.open(reviewURL)
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- guard currentAccumulatedUsageSeconds >= Constants.usageThresholdSeconds else { return }
|
|
|
|
|
- presentPrompt(source: .usageMilestone, on: presentationWindow())
|
|
|
|
|
|
|
+ requestAppStoreReview(from: window)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // MARK: - Prompt presentation
|
|
|
|
|
|
|
+ // MARK: - Automatic review
|
|
|
|
|
|
|
|
- private func scheduleDeferredPromptChecks() {
|
|
|
|
|
- pendingPromptWorkItem?.cancel()
|
|
|
|
|
|
|
+ private var isPremiumUser: Bool {
|
|
|
|
|
+ StoreManager.shared.isPremium
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- 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()
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ private func considerAutomaticReview(from window: NSWindow?) {
|
|
|
|
|
+ guard meetsAutomaticEligibility else { return }
|
|
|
|
|
+ guard Double.random(in: 0..<1) < Constants.automaticPromptProbability else { return }
|
|
|
|
|
|
|
|
- pendingPromptWorkItem = workItem
|
|
|
|
|
- DispatchQueue.main.asyncAfter(deadline: .now() + Constants.promptPresentationDelay, execute: workItem)
|
|
|
|
|
|
|
+ state.lastAutomaticPromptDate = Date()
|
|
|
|
|
+ state.save()
|
|
|
|
|
+ scheduleReviewRequest(from: window)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- 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()
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ private var meetsAutomaticEligibility: Bool {
|
|
|
|
|
+ guard isPremiumUser else { return false }
|
|
|
|
|
+ guard state.launchCount >= Constants.minLaunches else { return false }
|
|
|
|
|
|
|
|
- isShowingPrompt = true
|
|
|
|
|
|
|
+ guard let firstLaunchDate = state.firstLaunchDate else { return false }
|
|
|
|
|
+ guard Date().timeIntervalSince(firstLaunchDate) >= Constants.minDaysSinceInstall else { return false }
|
|
|
|
|
|
|
|
- let alert = NSAlert()
|
|
|
|
|
- alert.messageText = source.title
|
|
|
|
|
- alert.informativeText = source.message
|
|
|
|
|
- alert.alertStyle = .informational
|
|
|
|
|
- alert.addButton(withTitle: "Yes")
|
|
|
|
|
- alert.addButton(withTitle: "Not Really")
|
|
|
|
|
|
|
+ let printThreshold = state.randomPrintThreshold ?? Constants.printThresholdRange.lowerBound
|
|
|
|
|
+ guard state.successfulPrintCount >= printThreshold else { return false }
|
|
|
|
|
|
|
|
- let handleResponse: (NSApplication.ModalResponse) -> Void = { [weak self] response in
|
|
|
|
|
- self?.handlePromptResponse(source: source, response: response, window: window)
|
|
|
|
|
|
|
+ if let lastPromptDate = state.lastAutomaticPromptDate,
|
|
|
|
|
+ Date().timeIntervalSince(lastPromptDate) < Constants.cooldownBetweenPrompts {
|
|
|
|
|
+ return false
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if let window {
|
|
|
|
|
- alert.beginSheetModal(for: window) { response in
|
|
|
|
|
- handleResponse(response)
|
|
|
|
|
- }
|
|
|
|
|
- } else {
|
|
|
|
|
- handleResponse(alert.runModal())
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ return true
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- 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()
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ private func scheduleReviewRequest(from window: NSWindow?) {
|
|
|
|
|
+ pendingReviewWorkItem?.cancel()
|
|
|
|
|
|
|
|
- if response == .alertFirstButtonReturn {
|
|
|
|
|
- state.hasRequestedReview = true
|
|
|
|
|
- state.save()
|
|
|
|
|
- requestAppStoreReview(from: window)
|
|
|
|
|
- return
|
|
|
|
|
|
|
+ let workItem = DispatchWorkItem { [weak self] in
|
|
|
|
|
+ guard let self, self.isPremiumUser else { return }
|
|
|
|
|
+ self.requestAppStoreReview(from: window)
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- state.save()
|
|
|
|
|
- resumeUsageTrackingIfNeeded()
|
|
|
|
|
|
|
+ pendingReviewWorkItem = workItem
|
|
|
|
|
+ DispatchQueue.main.asyncAfter(
|
|
|
|
|
+ deadline: .now() + Constants.reviewPresentationDelay,
|
|
|
|
|
+ execute: workItem
|
|
|
|
|
+ )
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func requestAppStoreReview(from window: NSWindow?) {
|
|
private func requestAppStoreReview(from window: NSWindow?) {
|
|
|
|
|
+ guard isPremiumUser else { return }
|
|
|
|
|
+
|
|
|
let viewController = window?.contentViewController
|
|
let viewController = window?.contentViewController
|
|
|
?? presentationWindow()?.contentViewController
|
|
?? presentationWindow()?.contentViewController
|
|
|
?? NSApp.keyWindow?.contentViewController
|
|
?? NSApp.keyWindow?.contentViewController
|