|
|
@@ -9,6 +9,7 @@ import Cocoa
|
|
|
import QuartzCore
|
|
|
import WebKit
|
|
|
import AuthenticationServices
|
|
|
+import StoreKit
|
|
|
|
|
|
private enum SidebarPage: Int {
|
|
|
case joinMeetings = 0
|
|
|
@@ -38,6 +39,168 @@ private enum PremiumPlan: Int {
|
|
|
case lifetime = 3
|
|
|
}
|
|
|
|
|
|
+private enum PremiumStoreProduct {
|
|
|
+ static let weekly = "com.mqldev.meetingsapp.premium.weekly"
|
|
|
+ static let monthly = "com.mqldev.meetingsapp.premium.monthly"
|
|
|
+ static let yearly = "com.mqldev.meetingsapp.premium.yearly"
|
|
|
+ static let lifetime = "com.mqldev.meetingsapp.premium.lifetime"
|
|
|
+
|
|
|
+ static let allIDs = [weekly, monthly, yearly, lifetime]
|
|
|
+
|
|
|
+ static func productID(for plan: PremiumPlan) -> String {
|
|
|
+ switch plan {
|
|
|
+ case .weekly: return weekly
|
|
|
+ case .monthly: return monthly
|
|
|
+ case .yearly: return yearly
|
|
|
+ case .lifetime: return lifetime
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ static func plan(for productID: String) -> PremiumPlan? {
|
|
|
+ switch productID {
|
|
|
+ case weekly: return .weekly
|
|
|
+ case monthly: return .monthly
|
|
|
+ case yearly: return .yearly
|
|
|
+ case lifetime: return .lifetime
|
|
|
+ default: return nil
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@MainActor
|
|
|
+private final class StoreKitCoordinator {
|
|
|
+ enum PurchaseOutcome {
|
|
|
+ case success
|
|
|
+ case cancelled
|
|
|
+ case pending
|
|
|
+ case unavailable
|
|
|
+ case alreadyOwned
|
|
|
+ case failed(String)
|
|
|
+ }
|
|
|
+
|
|
|
+ private(set) var productsByID: [String: Product] = [:]
|
|
|
+ private(set) var activeEntitlementProductIDs: Set<String> = []
|
|
|
+ private(set) var lastProductLoadError: String?
|
|
|
+
|
|
|
+ var hasPremiumAccess: Bool { !activeEntitlementProductIDs.isEmpty }
|
|
|
+
|
|
|
+ private var transactionUpdatesTask: Task<Void, Never>?
|
|
|
+
|
|
|
+ deinit {
|
|
|
+ transactionUpdatesTask?.cancel()
|
|
|
+ }
|
|
|
+
|
|
|
+ func start() async {
|
|
|
+ if transactionUpdatesTask == nil {
|
|
|
+ transactionUpdatesTask = Task { [weak self] in
|
|
|
+ await self?.observeTransactionUpdates()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ await refreshProducts()
|
|
|
+ await refreshEntitlements()
|
|
|
+ }
|
|
|
+
|
|
|
+ func refreshProducts() async {
|
|
|
+ do {
|
|
|
+ let products = try await Product.products(for: PremiumStoreProduct.allIDs)
|
|
|
+ productsByID = Dictionary(uniqueKeysWithValues: products.map { ($0.id, $0) })
|
|
|
+ lastProductLoadError = nil
|
|
|
+ } catch {
|
|
|
+ productsByID = [:]
|
|
|
+ lastProductLoadError = error.localizedDescription
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func refreshEntitlements() async {
|
|
|
+ var active = Set<String>()
|
|
|
+ for await entitlement in Transaction.currentEntitlements {
|
|
|
+ guard case .verified(let transaction) = entitlement else { continue }
|
|
|
+ guard PremiumStoreProduct.allIDs.contains(transaction.productID) else { continue }
|
|
|
+ if Self.isTransactionActive(transaction) {
|
|
|
+ active.insert(transaction.productID)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ activeEntitlementProductIDs = active
|
|
|
+ }
|
|
|
+
|
|
|
+ func purchase(plan: PremiumPlan) async -> PurchaseOutcome {
|
|
|
+ let productID = PremiumStoreProduct.productID(for: plan)
|
|
|
+
|
|
|
+ if activeEntitlementProductIDs.contains(productID) {
|
|
|
+ return .alreadyOwned
|
|
|
+ }
|
|
|
+
|
|
|
+ guard let product = productsByID[productID] else {
|
|
|
+ await refreshProducts()
|
|
|
+ guard let refreshed = productsByID[productID] else {
|
|
|
+ if let lastProductLoadError, !lastProductLoadError.isEmpty {
|
|
|
+ return .failed("Unable to load products: \(lastProductLoadError)")
|
|
|
+ }
|
|
|
+ let loadedIDs = productsByID.keys.sorted().joined(separator: ", ")
|
|
|
+ let debugIDs = loadedIDs.isEmpty ? "none" : loadedIDs
|
|
|
+ return .failed("Product ID not found in StoreKit response. Requested: \(productID). Loaded IDs: \(debugIDs)")
|
|
|
+ }
|
|
|
+ return await purchase(product: refreshed)
|
|
|
+ }
|
|
|
+
|
|
|
+ return await purchase(product: product)
|
|
|
+ }
|
|
|
+
|
|
|
+ func restorePurchases() async -> String {
|
|
|
+ do {
|
|
|
+ try await AppStore.sync()
|
|
|
+ await refreshEntitlements()
|
|
|
+ if hasPremiumAccess {
|
|
|
+ return "Purchases restored successfully."
|
|
|
+ }
|
|
|
+ return "No previous premium purchase was found for this Apple ID."
|
|
|
+ } catch {
|
|
|
+ return "Restore failed: \(error.localizedDescription)"
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func purchase(product: Product) async -> PurchaseOutcome {
|
|
|
+ do {
|
|
|
+ let result = try await product.purchase()
|
|
|
+ switch result {
|
|
|
+ case .success(let verificationResult):
|
|
|
+ guard case .verified(let transaction) = verificationResult else {
|
|
|
+ return .failed("Purchase verification failed.")
|
|
|
+ }
|
|
|
+ await transaction.finish()
|
|
|
+ await refreshEntitlements()
|
|
|
+ return .success
|
|
|
+ case .pending:
|
|
|
+ return .pending
|
|
|
+ case .userCancelled:
|
|
|
+ return .cancelled
|
|
|
+ @unknown default:
|
|
|
+ return .failed("Unknown purchase state.")
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ return .failed(error.localizedDescription)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func observeTransactionUpdates() async {
|
|
|
+ for await update in Transaction.updates {
|
|
|
+ guard case .verified(let transaction) = update else { continue }
|
|
|
+ if PremiumStoreProduct.allIDs.contains(transaction.productID) {
|
|
|
+ await refreshEntitlements()
|
|
|
+ }
|
|
|
+ await transaction.finish()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func isTransactionActive(_ transaction: Transaction) -> Bool {
|
|
|
+ if transaction.revocationDate != nil { return false }
|
|
|
+ if let expirationDate = transaction.expirationDate {
|
|
|
+ return expirationDate > Date()
|
|
|
+ }
|
|
|
+ return true
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
final class ViewController: NSViewController {
|
|
|
private struct GoogleProfileDisplay {
|
|
|
let name: String
|
|
|
@@ -66,11 +229,19 @@ final class ViewController: NSViewController {
|
|
|
private var paywallPlanViews: [PremiumPlan: NSView] = [:]
|
|
|
private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
|
|
|
private weak var paywallOfferLabel: NSTextField?
|
|
|
+ private weak var paywallContinueLabel: NSTextField?
|
|
|
+ private weak var paywallContinueButton: NSView?
|
|
|
private weak var meetLinkField: NSTextField?
|
|
|
private weak var browseAddressField: NSTextField?
|
|
|
private var inAppBrowserWindowController: InAppBrowserWindowController?
|
|
|
private let googleOAuth = GoogleOAuthService.shared
|
|
|
private let calendarClient = GoogleCalendarClient()
|
|
|
+ private let storeKitCoordinator = StoreKitCoordinator()
|
|
|
+ private var storeKitStartupTask: Task<Void, Never>?
|
|
|
+ private var paywallPurchaseTask: Task<Void, Never>?
|
|
|
+ private var paywallPriceLabels: [PremiumPlan: NSTextField] = [:]
|
|
|
+ private var paywallSubtitleLabels: [PremiumPlan: NSTextField] = [:]
|
|
|
+ private var paywallContinueEnabled = true
|
|
|
|
|
|
private enum ScheduleFilter: Int {
|
|
|
case all = 0
|
|
|
@@ -139,6 +310,7 @@ final class ViewController: NSViewController {
|
|
|
palette = Palette(isDarkMode: darkModeEnabled)
|
|
|
setupRootView()
|
|
|
buildMainLayout()
|
|
|
+ startStoreKit()
|
|
|
}
|
|
|
|
|
|
override func viewDidAppear() {
|
|
|
@@ -167,6 +339,11 @@ final class ViewController: NSViewController {
|
|
|
override var representedObject: Any? {
|
|
|
didSet {}
|
|
|
}
|
|
|
+
|
|
|
+ deinit {
|
|
|
+ storeKitStartupTask?.cancel()
|
|
|
+ paywallPurchaseTask?.cancel()
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
private extension ViewController {
|
|
|
@@ -445,6 +622,11 @@ private extension ViewController {
|
|
|
settingsActionByView.removeAll()
|
|
|
paywallPlanViews.removeAll()
|
|
|
premiumPlanByView.removeAll()
|
|
|
+ paywallPriceLabels.removeAll()
|
|
|
+ paywallSubtitleLabels.removeAll()
|
|
|
+ paywallContinueLabel = nil
|
|
|
+ paywallContinueButton = nil
|
|
|
+ paywallContinueEnabled = true
|
|
|
|
|
|
googleAccountPopover?.performClose(nil)
|
|
|
googleAccountPopover = nil
|
|
|
@@ -459,7 +641,14 @@ private extension ViewController {
|
|
|
private func handleSettingsAction(_ action: SettingsAction) {
|
|
|
switch action {
|
|
|
case .restore:
|
|
|
- showSimpleAlert(title: "Restore", message: "Restore action tapped.")
|
|
|
+ settingsPopover?.performClose(nil)
|
|
|
+ settingsPopover = nil
|
|
|
+ Task { [weak self] in
|
|
|
+ guard let self else { return }
|
|
|
+ let message = await self.storeKitCoordinator.restorePurchases()
|
|
|
+ self.refreshPaywallStoreUI()
|
|
|
+ self.showSimpleAlert(title: "Restore Purchases", message: message)
|
|
|
+ }
|
|
|
case .rateUs:
|
|
|
settingsPopover?.performClose(nil)
|
|
|
settingsPopover = nil
|
|
|
@@ -529,6 +718,12 @@ private extension ViewController {
|
|
|
panel.makeKeyAndOrderFront(nil)
|
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
|
paywallWindow = panel
|
|
|
+
|
|
|
+ Task { [weak self] in
|
|
|
+ guard let self else { return }
|
|
|
+ await self.storeKitCoordinator.refreshProducts()
|
|
|
+ self.refreshPaywallStoreUI()
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
@objc private func closePaywallClicked(_ sender: Any?) {
|
|
|
@@ -582,6 +777,19 @@ private extension ViewController {
|
|
|
}
|
|
|
|
|
|
private func paywallOfferText(for plan: PremiumPlan) -> String {
|
|
|
+ if storeKitCoordinator.hasPremiumAccess {
|
|
|
+ return "Premium is active on this Apple ID."
|
|
|
+ }
|
|
|
+ let productID = PremiumStoreProduct.productID(for: plan)
|
|
|
+ if let product = storeKitCoordinator.productsByID[productID] {
|
|
|
+ if product.type == .nonConsumable {
|
|
|
+ return "\(product.displayPrice) one-time purchase"
|
|
|
+ }
|
|
|
+ if let period = product.subscription?.subscriptionPeriod {
|
|
|
+ return "\(product.displayPrice)/\(subscriptionUnitText(period.unit))"
|
|
|
+ }
|
|
|
+ return product.displayPrice
|
|
|
+ }
|
|
|
switch plan {
|
|
|
case .weekly:
|
|
|
return "Rs 1,100.00/week"
|
|
|
@@ -594,6 +802,99 @@ private extension ViewController {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ private func subscriptionUnitText(_ unit: Product.SubscriptionPeriod.Unit) -> String {
|
|
|
+ switch unit {
|
|
|
+ case .day: return "day"
|
|
|
+ case .week: return "week"
|
|
|
+ case .month: return "month"
|
|
|
+ case .year: return "year"
|
|
|
+ @unknown default: return "period"
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func startStoreKit() {
|
|
|
+ storeKitStartupTask?.cancel()
|
|
|
+ storeKitStartupTask = Task { [weak self] in
|
|
|
+ guard let self else { return }
|
|
|
+ await self.storeKitCoordinator.start()
|
|
|
+ self.refreshPaywallStoreUI()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func refreshPaywallStoreUI() {
|
|
|
+ for (plan, label) in paywallPriceLabels {
|
|
|
+ let productID = PremiumStoreProduct.productID(for: plan)
|
|
|
+ if let product = storeKitCoordinator.productsByID[productID] {
|
|
|
+ label.stringValue = product.displayPrice
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for (plan, label) in paywallSubtitleLabels {
|
|
|
+ let productID = PremiumStoreProduct.productID(for: plan)
|
|
|
+ guard let product = storeKitCoordinator.productsByID[productID],
|
|
|
+ let period = product.subscription?.subscriptionPeriod else { continue }
|
|
|
+ label.stringValue = "\(product.displayPrice)/\(subscriptionUnitText(period.unit))"
|
|
|
+ }
|
|
|
+ updatePaywallPlanSelection()
|
|
|
+ updatePaywallContinueState(isLoading: false)
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func paywallContinueClicked(_ sender: Any?) {
|
|
|
+ startSelectedPlanPurchase()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func startSelectedPlanPurchase() {
|
|
|
+ guard paywallContinueEnabled else {
|
|
|
+ if storeKitCoordinator.hasPremiumAccess {
|
|
|
+ showSimpleAlert(title: "Premium Active", message: "This Apple ID already has premium access.")
|
|
|
+ } else {
|
|
|
+ showSimpleAlert(title: "Please Wait", message: "A purchase is already being processed.")
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+ paywallPurchaseTask?.cancel()
|
|
|
+ updatePaywallContinueState(isLoading: true)
|
|
|
+ let selectedPlan = selectedPremiumPlan
|
|
|
+ paywallPurchaseTask = Task { [weak self] in
|
|
|
+ guard let self else { return }
|
|
|
+ let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
|
|
|
+ self.updatePaywallContinueState(isLoading: false)
|
|
|
+ self.refreshPaywallStoreUI()
|
|
|
+ switch result {
|
|
|
+ case .success:
|
|
|
+ self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
|
|
|
+ self.paywallWindow?.performClose(nil)
|
|
|
+ case .cancelled:
|
|
|
+ break
|
|
|
+ case .pending:
|
|
|
+ self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
|
|
|
+ case .unavailable:
|
|
|
+ self.showSimpleAlert(title: "Product Not Available", message: "Unable to load this product. Check your StoreKit configuration and product IDs.")
|
|
|
+ case .alreadyOwned:
|
|
|
+ self.showSimpleAlert(title: "Already Purchased", message: "This plan is already active on your Apple ID.")
|
|
|
+ case .failed(let message):
|
|
|
+ self.showSimpleAlert(title: "Purchase Failed", message: message)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func updatePaywallContinueState(isLoading: Bool) {
|
|
|
+ if isLoading {
|
|
|
+ paywallContinueEnabled = false
|
|
|
+ paywallContinueLabel?.stringValue = "Processing..."
|
|
|
+ paywallContinueButton?.alphaValue = 0.75
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if storeKitCoordinator.hasPremiumAccess {
|
|
|
+ paywallContinueEnabled = false
|
|
|
+ paywallContinueLabel?.stringValue = "Premium Active"
|
|
|
+ paywallContinueButton?.alphaValue = 0.75
|
|
|
+ } else {
|
|
|
+ paywallContinueEnabled = true
|
|
|
+ paywallContinueLabel?.stringValue = "Continue"
|
|
|
+ paywallContinueButton?.alphaValue = 1.0
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
|
|
|
let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
|
|
|
let idleBorder = palette.inputBorder
|
|
|
@@ -1270,8 +1571,10 @@ private extension ViewController {
|
|
|
contentStack.addArrangedSubview(offerWrap)
|
|
|
contentStack.setCustomSpacing(18, after: offerWrap)
|
|
|
|
|
|
- let continueButton = HoverTrackingView()
|
|
|
+ let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
|
|
|
continueButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ continueButton.isBordered = false
|
|
|
+ continueButton.bezelStyle = .regularSquare
|
|
|
continueButton.wantsLayer = true
|
|
|
continueButton.layer?.cornerRadius = 14
|
|
|
continueButton.layer?.backgroundColor = palette.primaryBlue.cgColor
|
|
|
@@ -1291,6 +1594,8 @@ private extension ViewController {
|
|
|
continueButton.layer?.backgroundColor = (hovering ? hoverBlue : baseBlue).cgColor
|
|
|
}
|
|
|
continueButton.onHoverChanged?(false)
|
|
|
+ paywallContinueButton = continueButton
|
|
|
+ paywallContinueLabel = continueLabel
|
|
|
contentStack.addArrangedSubview(continueButton)
|
|
|
contentStack.setCustomSpacing(16, after: continueButton)
|
|
|
|
|
|
@@ -1318,6 +1623,7 @@ private extension ViewController {
|
|
|
contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -12)
|
|
|
])
|
|
|
|
|
|
+ refreshPaywallStoreUI()
|
|
|
return panel
|
|
|
}
|
|
|
|
|
|
@@ -1378,6 +1684,7 @@ private extension ViewController {
|
|
|
card.addSubview(titleLabel)
|
|
|
let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 12, weight: .bold), color: palette.textPrimary)
|
|
|
card.addSubview(priceLabel)
|
|
|
+ paywallPriceLabels[plan] = priceLabel
|
|
|
|
|
|
NSLayoutConstraint.activate([
|
|
|
badgeWrap.centerXAnchor.constraint(equalTo: card.centerXAnchor),
|
|
|
@@ -1393,6 +1700,7 @@ private extension ViewController {
|
|
|
if let subtitle {
|
|
|
let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .semibold), color: palette.textSecondary)
|
|
|
card.addSubview(sub)
|
|
|
+ paywallSubtitleLabels[plan] = sub
|
|
|
NSLayoutConstraint.activate([
|
|
|
sub.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
|
|
|
sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 0)
|