|
|
@@ -115,6 +115,7 @@ final class StoreManager {
|
|
115
|
115
|
|
|
116
|
116
|
private(set) var products: [Product] = []
|
|
117
|
117
|
private(set) var isPremium = false
|
|
|
118
|
+ var isPro: Bool { isPremium }
|
|
118
|
119
|
private(set) var isLoadingProducts = false
|
|
119
|
120
|
private(set) var isPurchasing = false
|
|
120
|
121
|
|
|
|
@@ -221,6 +222,11 @@ final class StoreManager {
|
|
221
|
222
|
}
|
|
222
|
223
|
}
|
|
223
|
224
|
|
|
|
225
|
+ func showManageSubscriptions() {
|
|
|
226
|
+ guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
|
|
|
227
|
+ NSWorkspace.shared.open(url)
|
|
|
228
|
+ }
|
|
|
229
|
+
|
|
224
|
230
|
func showPurchaseError(_ error: Error, on window: NSWindow?) {
|
|
225
|
231
|
if let storeError = error as? StoreError {
|
|
226
|
232
|
showAlert(title: "Purchase Failed", message: storeError.localizedDescription, on: window)
|
|
|
@@ -244,20 +250,74 @@ final class StoreManager {
|
|
244
|
250
|
}
|
|
245
|
251
|
}
|
|
246
|
252
|
|
|
247
|
|
- private func refreshPremiumStatus() async {
|
|
248
|
|
- var hasPremium = false
|
|
|
253
|
+ func refreshPremiumStatus() async {
|
|
|
254
|
+ let hasPremium = await hasActivePremiumAccess()
|
|
|
255
|
+ let didChange = hasPremium != isPremium
|
|
|
256
|
+ isPremium = hasPremium
|
|
|
257
|
+
|
|
|
258
|
+ if didChange {
|
|
|
259
|
+ NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
|
|
|
260
|
+ }
|
|
|
261
|
+ postStoreStateDidChange()
|
|
|
262
|
+ }
|
|
249
|
263
|
|
|
|
264
|
+ private func hasActivePremiumAccess() async -> Bool {
|
|
|
265
|
+ if await hasEntitlementFromCurrentEntitlements() {
|
|
|
266
|
+ return true
|
|
|
267
|
+ }
|
|
|
268
|
+ if await hasEntitlementFromLatestTransactions() {
|
|
|
269
|
+ return true
|
|
|
270
|
+ }
|
|
|
271
|
+ if await hasEntitlementFromSubscriptionStatus() {
|
|
|
272
|
+ return true
|
|
|
273
|
+ }
|
|
|
274
|
+ return false
|
|
|
275
|
+ }
|
|
|
276
|
+
|
|
|
277
|
+ private func hasEntitlementFromCurrentEntitlements() async -> Bool {
|
|
250
|
278
|
for await result in Transaction.currentEntitlements {
|
|
251
|
279
|
guard let transaction = try? checkVerified(result) else { continue }
|
|
252
|
|
- if StoreProductID.all.contains(transaction.productID) {
|
|
253
|
|
- hasPremium = true
|
|
254
|
|
- break
|
|
|
280
|
+ if isActivePremiumTransaction(transaction) {
|
|
|
281
|
+ return true
|
|
255
|
282
|
}
|
|
256
|
283
|
}
|
|
|
284
|
+ return false
|
|
|
285
|
+ }
|
|
257
|
286
|
|
|
258
|
|
- guard hasPremium != isPremium else { return }
|
|
259
|
|
- isPremium = hasPremium
|
|
260
|
|
- NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
|
|
|
287
|
+ private func hasEntitlementFromLatestTransactions() async -> Bool {
|
|
|
288
|
+ for productID in StoreProductID.all {
|
|
|
289
|
+ guard let result = await Transaction.latest(for: productID),
|
|
|
290
|
+ let transaction = try? checkVerified(result) else { continue }
|
|
|
291
|
+ if isActivePremiumTransaction(transaction) {
|
|
|
292
|
+ return true
|
|
|
293
|
+ }
|
|
|
294
|
+ }
|
|
|
295
|
+ return false
|
|
|
296
|
+ }
|
|
|
297
|
+
|
|
|
298
|
+ private func hasEntitlementFromSubscriptionStatus() async -> Bool {
|
|
|
299
|
+ for product in products where product.subscription != nil {
|
|
|
300
|
+ guard StoreProductID.all.contains(product.id) else { continue }
|
|
|
301
|
+ guard let statuses = try? await product.subscription?.status else { continue }
|
|
|
302
|
+ for status in statuses {
|
|
|
303
|
+ switch status.state {
|
|
|
304
|
+ case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
|
|
|
305
|
+ return true
|
|
|
306
|
+ default:
|
|
|
307
|
+ continue
|
|
|
308
|
+ }
|
|
|
309
|
+ }
|
|
|
310
|
+ }
|
|
|
311
|
+ return false
|
|
|
312
|
+ }
|
|
|
313
|
+
|
|
|
314
|
+ private func isActivePremiumTransaction(_ transaction: Transaction) -> Bool {
|
|
|
315
|
+ guard StoreProductID.all.contains(transaction.productID) else { return false }
|
|
|
316
|
+ guard transaction.revocationDate == nil else { return false }
|
|
|
317
|
+ if let expirationDate = transaction.expirationDate {
|
|
|
318
|
+ return expirationDate > Date()
|
|
|
319
|
+ }
|
|
|
320
|
+ return true
|
|
261
|
321
|
}
|
|
262
|
322
|
|
|
263
|
323
|
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
|
|
|
@@ -582,8 +642,9 @@ private final class PaywallFooterLink: NSButton, AppearanceRefreshable {
|
|
582
|
642
|
|
|
583
|
643
|
init(title: String) {
|
|
584
|
644
|
super.init(frame: .zero)
|
|
585
|
|
- self.title = title
|
|
|
645
|
+ updateTitle(title)
|
|
586
|
646
|
isBordered = false
|
|
|
647
|
+ bezelStyle = .inline
|
|
587
|
648
|
font = AppTheme.regularFont(size: 11)
|
|
588
|
649
|
translatesAutoresizingMaskIntoConstraints = false
|
|
589
|
650
|
refreshAppearance()
|
|
|
@@ -597,6 +658,12 @@ private final class PaywallFooterLink: NSButton, AppearanceRefreshable {
|
|
597
|
658
|
@available(*, unavailable)
|
|
598
|
659
|
required init?(coder: NSCoder) { nil }
|
|
599
|
660
|
|
|
|
661
|
+ func updateTitle(_ title: String) {
|
|
|
662
|
+ self.title = title
|
|
|
663
|
+ invalidateIntrinsicContentSize()
|
|
|
664
|
+ needsDisplay = true
|
|
|
665
|
+ }
|
|
|
666
|
+
|
|
600
|
667
|
func refreshAppearance() {
|
|
601
|
668
|
contentTintColor = isHovered ? AppTheme.textPrimary : AppTheme.textSecondary
|
|
602
|
669
|
}
|
|
|
@@ -733,6 +800,9 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
733
|
800
|
private var selectedPlan: PaywallPlan = .yearly
|
|
734
|
801
|
private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
|
|
735
|
802
|
private let ctaButton = PaywallCTAButton()
|
|
|
803
|
+ private let continueFreePlanLink = PaywallFooterLink(title: "Continue with free plan")
|
|
|
804
|
+ private let manageSubscriptionLink = PaywallFooterLink(title: "Manage Subscription")
|
|
|
805
|
+ private var primaryFooterLinkCell: NSView?
|
|
736
|
806
|
private var leftPanelTitle: NSTextField!
|
|
737
|
807
|
private var rightTitle: NSTextField!
|
|
738
|
808
|
private var rightSubtitle: NSTextField!
|
|
|
@@ -783,6 +853,19 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
783
|
853
|
let isBusy = store.isPurchasing || store.isLoadingProducts
|
|
784
|
854
|
ctaButton.isEnabled = !isBusy
|
|
785
|
855
|
ctaButton.alphaValue = isBusy ? 0.65 : 1
|
|
|
856
|
+ refreshPrimaryFooterLink()
|
|
|
857
|
+ }
|
|
|
858
|
+
|
|
|
859
|
+ private func refreshPrimaryFooterLink() {
|
|
|
860
|
+ let isPro = StoreManager.shared.isPro
|
|
|
861
|
+ continueFreePlanLink.isHidden = isPro
|
|
|
862
|
+ manageSubscriptionLink.isHidden = !isPro
|
|
|
863
|
+ primaryFooterLinkCell?.needsLayout = true
|
|
|
864
|
+ primaryFooterLinkCell?.layoutSubtreeIfNeeded()
|
|
|
865
|
+ }
|
|
|
866
|
+
|
|
|
867
|
+ func refreshStoreState() {
|
|
|
868
|
+ refreshProductDisplay()
|
|
786
|
869
|
}
|
|
787
|
870
|
|
|
788
|
871
|
func refreshAppearance() {
|
|
|
@@ -987,9 +1070,14 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
987
|
1070
|
let container = NSView()
|
|
988
|
1071
|
container.translatesAutoresizingMaskIntoConstraints = false
|
|
989
|
1072
|
|
|
990
|
|
- let continueWithFreePlanLink = PaywallFooterLink(title: "Continue with free plan")
|
|
991
|
|
- continueWithFreePlanLink.target = self
|
|
992
|
|
- continueWithFreePlanLink.action = #selector(continueWithFreePlanTapped)
|
|
|
1073
|
+ continueFreePlanLink.target = self
|
|
|
1074
|
+ continueFreePlanLink.action = #selector(continueWithFreePlanTapped)
|
|
|
1075
|
+ manageSubscriptionLink.target = self
|
|
|
1076
|
+ manageSubscriptionLink.action = #selector(manageSubscriptionTapped)
|
|
|
1077
|
+
|
|
|
1078
|
+ let primaryCell = makePrimaryFooterLinkCell()
|
|
|
1079
|
+ primaryFooterLinkCell = primaryCell
|
|
|
1080
|
+ refreshPrimaryFooterLink()
|
|
993
|
1081
|
|
|
994
|
1082
|
let restoreLink = PaywallFooterLink(title: "Restore Purchase")
|
|
995
|
1083
|
restoreLink.target = self
|
|
|
@@ -999,16 +1087,14 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
999
|
1087
|
let termsLink = PaywallFooterLink(title: "Terms of Service")
|
|
1000
|
1088
|
let supportLink = PaywallFooterLink(title: "Support")
|
|
1001
|
1089
|
|
|
1002
|
|
- let links = [
|
|
1003
|
|
- continueWithFreePlanLink,
|
|
1004
|
|
- restoreLink,
|
|
1005
|
|
- privacyLink,
|
|
1006
|
|
- termsLink,
|
|
1007
|
|
- supportLink,
|
|
|
1090
|
+ let linkCells = [
|
|
|
1091
|
+ primaryCell,
|
|
|
1092
|
+ makeFooterLinkCell(link: restoreLink),
|
|
|
1093
|
+ makeFooterLinkCell(link: privacyLink),
|
|
|
1094
|
+ makeFooterLinkCell(link: termsLink),
|
|
|
1095
|
+ makeFooterLinkCell(link: supportLink),
|
|
1008
|
1096
|
]
|
|
1009
|
1097
|
|
|
1010
|
|
- let linkCells = links.map { makeFooterLinkCell(link: $0) }
|
|
1011
|
|
-
|
|
1012
|
1098
|
let linksStack = NSStackView(views: linkCells)
|
|
1013
|
1099
|
linksStack.orientation = .horizontal
|
|
1014
|
1100
|
linksStack.spacing = 0
|
|
|
@@ -1027,6 +1113,23 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
1027
|
1113
|
return container
|
|
1028
|
1114
|
}
|
|
1029
|
1115
|
|
|
|
1116
|
+ private func makePrimaryFooterLinkCell() -> NSView {
|
|
|
1117
|
+ let cell = NSView()
|
|
|
1118
|
+ cell.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1119
|
+
|
|
|
1120
|
+ for link in [continueFreePlanLink, manageSubscriptionLink] {
|
|
|
1121
|
+ cell.addSubview(link)
|
|
|
1122
|
+ NSLayoutConstraint.activate([
|
|
|
1123
|
+ link.centerXAnchor.constraint(equalTo: cell.centerXAnchor),
|
|
|
1124
|
+ link.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
|
|
1125
|
+ link.topAnchor.constraint(equalTo: cell.topAnchor),
|
|
|
1126
|
+ link.bottomAnchor.constraint(equalTo: cell.bottomAnchor),
|
|
|
1127
|
+ ])
|
|
|
1128
|
+ }
|
|
|
1129
|
+
|
|
|
1130
|
+ return cell
|
|
|
1131
|
+ }
|
|
|
1132
|
+
|
|
1030
|
1133
|
private func makeFooterLinkCell(link: PaywallFooterLink) -> NSView {
|
|
1031
|
1134
|
let cell = NSView()
|
|
1032
|
1135
|
cell.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -1055,7 +1158,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
1055
|
1158
|
refreshPurchaseState()
|
|
1056
|
1159
|
do {
|
|
1057
|
1160
|
let succeeded = try await StoreManager.shared.purchase(plan: selectedPlan)
|
|
1058
|
|
- refreshPurchaseState()
|
|
|
1161
|
+ refreshStoreState()
|
|
1059
|
1162
|
if succeeded {
|
|
1060
|
1163
|
onPurchaseSucceeded?()
|
|
1061
|
1164
|
}
|
|
|
@@ -1071,7 +1174,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
1071
|
1174
|
refreshPurchaseState()
|
|
1072
|
1175
|
do {
|
|
1073
|
1176
|
let restored = try await StoreManager.shared.restorePurchases()
|
|
1074
|
|
- refreshPurchaseState()
|
|
|
1177
|
+ refreshStoreState()
|
|
1075
|
1178
|
if restored {
|
|
1076
|
1179
|
onPurchaseSucceeded?()
|
|
1077
|
1180
|
} else {
|
|
|
@@ -1091,6 +1194,10 @@ final class PaywallView: NSView, AppearanceRefreshable {
|
|
1091
|
1194
|
@objc private func continueWithFreePlanTapped() {
|
|
1092
|
1195
|
onClose?()
|
|
1093
|
1196
|
}
|
|
|
1197
|
+
|
|
|
1198
|
+ @objc private func manageSubscriptionTapped() {
|
|
|
1199
|
+ StoreManager.shared.showManageSubscriptions()
|
|
|
1200
|
+ }
|
|
1094
|
1201
|
}
|
|
1095
|
1202
|
|
|
1096
|
1203
|
// MARK: - Overlay Presenter
|
|
|
@@ -1118,6 +1225,10 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
|
|
1118
|
1225
|
paywallView.refreshAppearance()
|
|
1119
|
1226
|
}
|
|
1120
|
1227
|
|
|
|
1228
|
+ func refreshStoreState() {
|
|
|
1229
|
+ paywallView.refreshStoreState()
|
|
|
1230
|
+ }
|
|
|
1231
|
+
|
|
1121
|
1232
|
@available(*, unavailable)
|
|
1122
|
1233
|
required init?(coder: NSCoder) { nil }
|
|
1123
|
1234
|
|
|
|
@@ -1137,7 +1248,7 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
|
|
1137
|
1248
|
paywallView.translatesAutoresizingMaskIntoConstraints = false
|
|
1138
|
1249
|
paywallView.onClose = { [weak self] in self?.dismiss() }
|
|
1139
|
1250
|
paywallView.onPurchaseSucceeded = { [weak self] in
|
|
1140
|
|
- self?.dismiss()
|
|
|
1251
|
+ self?.paywallView.refreshStoreState()
|
|
1141
|
1252
|
}
|
|
1142
|
1253
|
|
|
1143
|
1254
|
addSubview(blurView)
|
|
|
@@ -1193,10 +1304,9 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
|
|
1193
|
1304
|
if StoreManager.shared.products.isEmpty {
|
|
1194
|
1305
|
await StoreManager.shared.loadProducts()
|
|
1195
|
1306
|
}
|
|
1196
|
|
- }
|
|
1197
|
|
- NSAnimationContext.runAnimationGroup { context in
|
|
1198
|
|
- context.duration = 0.2
|
|
1199
|
|
- animator().alphaValue = 1
|
|
|
1307
|
+ await StoreManager.shared.refreshPremiumStatus()
|
|
|
1308
|
+ paywallView.refreshStoreState()
|
|
|
1309
|
+ alphaValue = 1
|
|
1200
|
1310
|
}
|
|
1201
|
1311
|
}
|
|
1202
|
1312
|
|