|
|
@@ -115,6 +115,7 @@ final class StoreManager {
|
|
|
|
|
|
private(set) var products: [Product] = []
|
|
|
private(set) var isPremium = false
|
|
|
+ var isPro: Bool { isPremium }
|
|
|
private(set) var isLoadingProducts = false
|
|
|
private(set) var isPurchasing = false
|
|
|
|
|
|
@@ -221,6 +222,11 @@ final class StoreManager {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ func showManageSubscriptions() {
|
|
|
+ guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
|
|
|
+ NSWorkspace.shared.open(url)
|
|
|
+ }
|
|
|
+
|
|
|
func showPurchaseError(_ error: Error, on window: NSWindow?) {
|
|
|
if let storeError = error as? StoreError {
|
|
|
showAlert(title: "Purchase Failed", message: storeError.localizedDescription, on: window)
|
|
|
@@ -244,20 +250,74 @@ final class StoreManager {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private func refreshPremiumStatus() async {
|
|
|
- var hasPremium = false
|
|
|
+ func refreshPremiumStatus() async {
|
|
|
+ let hasPremium = await hasActivePremiumAccess()
|
|
|
+ let didChange = hasPremium != isPremium
|
|
|
+ isPremium = hasPremium
|
|
|
+
|
|
|
+ if didChange {
|
|
|
+ NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
|
|
|
+ }
|
|
|
+ postStoreStateDidChange()
|
|
|
+ }
|
|
|
|
|
|
+ private func hasActivePremiumAccess() async -> Bool {
|
|
|
+ if await hasEntitlementFromCurrentEntitlements() {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ if await hasEntitlementFromLatestTransactions() {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ if await hasEntitlementFromSubscriptionStatus() {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ private func hasEntitlementFromCurrentEntitlements() async -> Bool {
|
|
|
for await result in Transaction.currentEntitlements {
|
|
|
guard let transaction = try? checkVerified(result) else { continue }
|
|
|
- if StoreProductID.all.contains(transaction.productID) {
|
|
|
- hasPremium = true
|
|
|
- break
|
|
|
+ if isActivePremiumTransaction(transaction) {
|
|
|
+ return true
|
|
|
}
|
|
|
}
|
|
|
+ return false
|
|
|
+ }
|
|
|
|
|
|
- guard hasPremium != isPremium else { return }
|
|
|
- isPremium = hasPremium
|
|
|
- NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
|
|
|
+ private func hasEntitlementFromLatestTransactions() async -> Bool {
|
|
|
+ for productID in StoreProductID.all {
|
|
|
+ guard let result = await Transaction.latest(for: productID),
|
|
|
+ let transaction = try? checkVerified(result) else { continue }
|
|
|
+ if isActivePremiumTransaction(transaction) {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ private func hasEntitlementFromSubscriptionStatus() async -> Bool {
|
|
|
+ for product in products where product.subscription != nil {
|
|
|
+ guard StoreProductID.all.contains(product.id) else { continue }
|
|
|
+ guard let statuses = try? await product.subscription?.status else { continue }
|
|
|
+ for status in statuses {
|
|
|
+ switch status.state {
|
|
|
+ case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
|
|
|
+ return true
|
|
|
+ default:
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ private func isActivePremiumTransaction(_ transaction: Transaction) -> Bool {
|
|
|
+ guard StoreProductID.all.contains(transaction.productID) else { return false }
|
|
|
+ guard transaction.revocationDate == nil else { return false }
|
|
|
+ if let expirationDate = transaction.expirationDate {
|
|
|
+ return expirationDate > Date()
|
|
|
+ }
|
|
|
+ return true
|
|
|
}
|
|
|
|
|
|
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
|
|
|
@@ -582,8 +642,9 @@ private final class PaywallFooterLink: NSButton, AppearanceRefreshable {
|
|
|
|
|
|
init(title: String) {
|
|
|
super.init(frame: .zero)
|
|
|
- self.title = title
|
|
|
+ updateTitle(title)
|
|
|
isBordered = false
|
|
|
+ bezelStyle = .inline
|
|
|
font = AppTheme.regularFont(size: 11)
|
|
|
translatesAutoresizingMaskIntoConstraints = false
|
|
|
refreshAppearance()
|
|
|
@@ -597,6 +658,12 @@ private final class PaywallFooterLink: NSButton, AppearanceRefreshable {
|
|
|
@available(*, unavailable)
|
|
|
required init?(coder: NSCoder) { nil }
|
|
|
|
|
|
+ func updateTitle(_ title: String) {
|
|
|
+ self.title = title
|
|
|
+ invalidateIntrinsicContentSize()
|
|
|
+ needsDisplay = true
|
|
|
+ }
|
|
|
+
|
|
|
func refreshAppearance() {
|
|
|
contentTintColor = isHovered ? AppTheme.textPrimary : AppTheme.textSecondary
|
|
|
}
|
|
|
@@ -733,6 +800,9 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
private var selectedPlan: PaywallPlan = .yearly
|
|
|
private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
|
|
|
private let ctaButton = PaywallCTAButton()
|
|
|
+ private let continueFreePlanLink = PaywallFooterLink(title: "Continue with free plan")
|
|
|
+ private let manageSubscriptionLink = PaywallFooterLink(title: "Manage Subscription")
|
|
|
+ private var primaryFooterLinkCell: NSView?
|
|
|
private var leftPanelTitle: NSTextField!
|
|
|
private var rightTitle: NSTextField!
|
|
|
private var rightSubtitle: NSTextField!
|
|
|
@@ -783,6 +853,19 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
let isBusy = store.isPurchasing || store.isLoadingProducts
|
|
|
ctaButton.isEnabled = !isBusy
|
|
|
ctaButton.alphaValue = isBusy ? 0.65 : 1
|
|
|
+ refreshPrimaryFooterLink()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func refreshPrimaryFooterLink() {
|
|
|
+ let isPro = StoreManager.shared.isPro
|
|
|
+ continueFreePlanLink.isHidden = isPro
|
|
|
+ manageSubscriptionLink.isHidden = !isPro
|
|
|
+ primaryFooterLinkCell?.needsLayout = true
|
|
|
+ primaryFooterLinkCell?.layoutSubtreeIfNeeded()
|
|
|
+ }
|
|
|
+
|
|
|
+ func refreshStoreState() {
|
|
|
+ refreshProductDisplay()
|
|
|
}
|
|
|
|
|
|
func refreshAppearance() {
|
|
|
@@ -987,9 +1070,14 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
let container = NSView()
|
|
|
container.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
- let continueWithFreePlanLink = PaywallFooterLink(title: "Continue with free plan")
|
|
|
- continueWithFreePlanLink.target = self
|
|
|
- continueWithFreePlanLink.action = #selector(continueWithFreePlanTapped)
|
|
|
+ continueFreePlanLink.target = self
|
|
|
+ continueFreePlanLink.action = #selector(continueWithFreePlanTapped)
|
|
|
+ manageSubscriptionLink.target = self
|
|
|
+ manageSubscriptionLink.action = #selector(manageSubscriptionTapped)
|
|
|
+
|
|
|
+ let primaryCell = makePrimaryFooterLinkCell()
|
|
|
+ primaryFooterLinkCell = primaryCell
|
|
|
+ refreshPrimaryFooterLink()
|
|
|
|
|
|
let restoreLink = PaywallFooterLink(title: "Restore Purchase")
|
|
|
restoreLink.target = self
|
|
|
@@ -999,16 +1087,14 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
let termsLink = PaywallFooterLink(title: "Terms of Service")
|
|
|
let supportLink = PaywallFooterLink(title: "Support")
|
|
|
|
|
|
- let links = [
|
|
|
- continueWithFreePlanLink,
|
|
|
- restoreLink,
|
|
|
- privacyLink,
|
|
|
- termsLink,
|
|
|
- supportLink,
|
|
|
+ let linkCells = [
|
|
|
+ primaryCell,
|
|
|
+ makeFooterLinkCell(link: restoreLink),
|
|
|
+ makeFooterLinkCell(link: privacyLink),
|
|
|
+ makeFooterLinkCell(link: termsLink),
|
|
|
+ makeFooterLinkCell(link: supportLink),
|
|
|
]
|
|
|
|
|
|
- let linkCells = links.map { makeFooterLinkCell(link: $0) }
|
|
|
-
|
|
|
let linksStack = NSStackView(views: linkCells)
|
|
|
linksStack.orientation = .horizontal
|
|
|
linksStack.spacing = 0
|
|
|
@@ -1027,6 +1113,23 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
return container
|
|
|
}
|
|
|
|
|
|
+ private func makePrimaryFooterLinkCell() -> NSView {
|
|
|
+ let cell = NSView()
|
|
|
+ cell.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ for link in [continueFreePlanLink, manageSubscriptionLink] {
|
|
|
+ cell.addSubview(link)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ link.centerXAnchor.constraint(equalTo: cell.centerXAnchor),
|
|
|
+ link.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
|
|
+ link.topAnchor.constraint(equalTo: cell.topAnchor),
|
|
|
+ link.bottomAnchor.constraint(equalTo: cell.bottomAnchor),
|
|
|
+ ])
|
|
|
+ }
|
|
|
+
|
|
|
+ return cell
|
|
|
+ }
|
|
|
+
|
|
|
private func makeFooterLinkCell(link: PaywallFooterLink) -> NSView {
|
|
|
let cell = NSView()
|
|
|
cell.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -1055,7 +1158,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
refreshPurchaseState()
|
|
|
do {
|
|
|
let succeeded = try await StoreManager.shared.purchase(plan: selectedPlan)
|
|
|
- refreshPurchaseState()
|
|
|
+ refreshStoreState()
|
|
|
if succeeded {
|
|
|
onPurchaseSucceeded?()
|
|
|
}
|
|
|
@@ -1071,7 +1174,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
refreshPurchaseState()
|
|
|
do {
|
|
|
let restored = try await StoreManager.shared.restorePurchases()
|
|
|
- refreshPurchaseState()
|
|
|
+ refreshStoreState()
|
|
|
if restored {
|
|
|
onPurchaseSucceeded?()
|
|
|
} else {
|
|
|
@@ -1091,6 +1194,10 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
|
@objc private func continueWithFreePlanTapped() {
|
|
|
onClose?()
|
|
|
}
|
|
|
+
|
|
|
+ @objc private func manageSubscriptionTapped() {
|
|
|
+ StoreManager.shared.showManageSubscriptions()
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// MARK: - Overlay Presenter
|
|
|
@@ -1118,6 +1225,10 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
|
|
|
paywallView.refreshAppearance()
|
|
|
}
|
|
|
|
|
|
+ func refreshStoreState() {
|
|
|
+ paywallView.refreshStoreState()
|
|
|
+ }
|
|
|
+
|
|
|
@available(*, unavailable)
|
|
|
required init?(coder: NSCoder) { nil }
|
|
|
|
|
|
@@ -1137,7 +1248,7 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
|
|
|
paywallView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
paywallView.onClose = { [weak self] in self?.dismiss() }
|
|
|
paywallView.onPurchaseSucceeded = { [weak self] in
|
|
|
- self?.dismiss()
|
|
|
+ self?.paywallView.refreshStoreState()
|
|
|
}
|
|
|
|
|
|
addSubview(blurView)
|
|
|
@@ -1193,10 +1304,9 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
|
|
|
if StoreManager.shared.products.isEmpty {
|
|
|
await StoreManager.shared.loadProducts()
|
|
|
}
|
|
|
- }
|
|
|
- NSAnimationContext.runAnimationGroup { context in
|
|
|
- context.duration = 0.2
|
|
|
- animator().alphaValue = 1
|
|
|
+ await StoreManager.shared.refreshPremiumStatus()
|
|
|
+ paywallView.refreshStoreState()
|
|
|
+ alphaValue = 1
|
|
|
}
|
|
|
}
|
|
|
|