| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427 |
- import Cocoa
- import StoreKit
- // MARK: - StoreKit
- enum StoreProductID {
- static let monthly = AppStoreConfig.ProductID.monthly
- static let yearly = AppStoreConfig.ProductID.yearly
- static let lifetime = AppStoreConfig.ProductID.lifetime
- static let all = AppStoreConfig.ProductID.all
- static func plan(for productID: String) -> PaywallPlan? {
- switch productID {
- case monthly: .monthly
- case yearly: .yearly
- case lifetime: .lifetime
- default: nil
- }
- }
- }
- enum PremiumAccessKind: String {
- case none
- case subscription
- case lifetime
- }
- enum StoreError: Error {
- case productNotFound
- case failedVerification
- }
- enum PurchaseOutcome: Sendable {
- case purchased
- case cancelled
- case pending
- }
- @MainActor
- final class StoreManager {
- static let shared = StoreManager()
- private enum ProductLoadError: Error {
- case timeout
- }
- private enum StoreSyncError: Error {
- case timeout
- }
- private static let cachedPremiumKey = "StoreManager.cachedIsPremium"
- private static let cachedPremiumPlanKey = "StoreManager.cachedPremiumPlan"
- private static let cachedHasEverPurchasedKey = "StoreManager.cachedHasEverPurchased"
- private static let backgroundRefreshInterval: TimeInterval = 120
- private(set) var productsByID: [String: Product] = [:]
- private(set) var isPremium = UserDefaults.standard.bool(forKey: cachedPremiumKey)
- private(set) var activePremiumPlan: PaywallPlan?
- private(set) var hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: cachedHasEverPurchasedKey)
- private(set) var trialDisplayByPlan: [PaywallPlan: PaywallTrialDisplay] = [:]
- private(set) var purchaseError: PurchaseError?
- var isPro: Bool { isPremium }
- var hasActiveSubscription: Bool { activePremiumPlan == .monthly || activePremiumPlan == .yearly }
- var hasLifetimeAccess: Bool { activePremiumPlan == .lifetime }
- var premiumAccessKind: PremiumAccessKind {
- switch activePremiumPlan {
- case .lifetime: .lifetime
- case .monthly, .yearly: .subscription
- case nil: .none
- }
- }
- var products: [Product] {
- availablePlans.compactMap { productsByID[$0.productID] }
- }
- var availablePlans: [PaywallPlan] {
- PaywallPlan.displayOrder.filter { productsByID[$0.productID] != nil }
- }
- var isResolvingEntitlements: Bool { !hasResolvedPremiumStatus }
- var hasResolvedPremiumStatus = false
- var hasAvailablePlans: Bool { !availablePlans.isEmpty }
- var hasAllProductsLoaded: Bool { hasAvailablePlans }
- private(set) var isLoadingProducts = false
- private(set) var productLoadError: String?
- private(set) var purchasingPlan: PaywallPlan?
- private(set) var isRestoringPurchases = false
- var isPurchasing: Bool { purchasingPlan != nil || isRestoringPurchases }
- private var inFlightProductLoad: Task<Void, Never>?
- private var transactionListener: Task<Void, Never>?
- private var initialEntitlementTask: Task<Void, Never>?
- private var entitlementRefreshTask: Task<Void, Never>?
- private var hasStarted = false
- private var lastBackgroundEntitlementRefresh: Date?
- private var appActiveObserver: NSObjectProtocol?
- private init() {
- migrateLegacyPremiumCacheIfNeeded()
- hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: Self.cachedHasEverPurchasedKey)
- if UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) {
- isPremium = true
- activePremiumPlan = restoredCachedPremiumPlan()
- }
- }
- private func migrateLegacyPremiumCacheIfNeeded() {
- guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return }
- guard UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey) == nil,
- let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") else {
- return
- }
- let migratedPlan: String?
- switch legacyKind {
- case PremiumAccessKind.lifetime.rawValue:
- migratedPlan = PaywallPlan.lifetime.rawValue
- case PremiumAccessKind.subscription.rawValue:
- migratedPlan = PaywallPlan.yearly.rawValue
- default:
- migratedPlan = nil
- }
- if let migratedPlan {
- UserDefaults.standard.set(migratedPlan, forKey: Self.cachedPremiumPlanKey)
- }
- }
- private func restoredCachedPremiumPlan() -> PaywallPlan? {
- guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return nil }
- if let raw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
- let plan = PaywallPlan(rawValue: raw) {
- return plan
- }
- if let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") {
- switch legacyKind {
- case PremiumAccessKind.lifetime.rawValue:
- return .lifetime
- case PremiumAccessKind.subscription.rawValue:
- return .yearly
- default:
- break
- }
- }
- return .yearly
- }
- func start() {
- guard !hasStarted else { return }
- hasStarted = true
- observeAppLifecycle()
- transactionListener = Task { [weak self] in
- await self?.listenForTransactionUpdates()
- }
- initialEntitlementTask = Task { [weak self] in
- guard let self else { return }
- await self.refreshPremiumAccess()
- await self.loadProducts(presentLoadingUI: false)
- }
- }
- private func observeAppLifecycle() {
- appActiveObserver = NotificationCenter.default.addObserver(
- forName: NSApplication.didBecomeActiveNotification,
- object: nil,
- queue: .main
- ) { [weak self] _ in
- Task { @MainActor in
- self?.refreshEntitlementsOnAppActive()
- }
- }
- }
- private func refreshEntitlementsOnAppActive() {
- guard hasStarted else { return }
- if let lastRefresh = lastBackgroundEntitlementRefresh,
- Date().timeIntervalSince(lastRefresh) < Self.backgroundRefreshInterval {
- return
- }
- entitlementRefreshTask?.cancel()
- entitlementRefreshTask = Task {
- await refreshPremiumAccess()
- lastBackgroundEntitlementRefresh = Date()
- }
- }
- func ensureEntitlementsResolved() async {
- await initialEntitlementTask?.value
- }
- func ensurePaywallStoreDataLoaded(presentLoadingUI: Bool = true) async {
- await ensureEntitlementsResolved()
- await loadProducts(presentLoadingUI: presentLoadingUI && !hasAllProductsLoaded)
- await refreshTrialOffers()
- }
- func product(for plan: PaywallPlan) -> Product? {
- productsByID[plan.productID]
- }
- func trialDisplay(for plan: PaywallPlan) -> PaywallTrialDisplay? {
- trialDisplayByPlan[plan]
- }
- func clearPurchaseError() {
- purchaseError = nil
- }
- var isEligibleForIntroTrial: Bool {
- !hasEverPurchasedPremium && !isPremium
- }
- func loadProducts(presentLoadingUI: Bool? = nil) async {
- if let existing = inFlightProductLoad {
- await existing.value
- return
- }
- let uiFlag = presentLoadingUI
- let task = Task { @MainActor in
- await performLoadProducts(presentLoadingUI: uiFlag)
- }
- inFlightProductLoad = task
- await task.value
- inFlightProductLoad = nil
- }
- @discardableResult
- func purchase(plan: PaywallPlan) async -> Bool {
- purchaseError = nil
- var product = productsByID[plan.productID]
- if product == nil {
- await loadProducts(presentLoadingUI: false)
- product = productsByID[plan.productID]
- }
- guard let product else {
- purchaseError = products.isEmpty ? .subscriptionUnavailable : .planUnavailable
- return false
- }
- purchasingPlan = plan
- postStoreStateDidChange()
- defer {
- purchasingPlan = nil
- postStoreStateDidChange()
- }
- do {
- let result = try await product.purchase()
- switch result {
- case .success(let verification):
- guard case .verified(let transaction) = verification else {
- purchaseError = .purchaseNotVerified
- return false
- }
- let trustedPlan = planGrantingPremium(from: transaction)
- if let trustedPlan {
- applyPremiumAccess(from: transaction)
- }
- await transaction.finish()
- await reconcilePremiumAccess(trustedPlan: trustedPlan)
- return isPremium
- case .userCancelled:
- return false
- case .pending:
- purchaseError = .purchasePending
- return false
- @unknown default:
- return false
- }
- } catch {
- purchaseError = .generic
- return false
- }
- }
- @discardableResult
- func restorePurchases() async -> Bool {
- purchaseError = nil
- isRestoringPurchases = true
- postStoreStateDidChange()
- defer {
- isRestoringPurchases = false
- postStoreStateDidChange()
- }
- do {
- try await syncAppStoreWithTimeout(seconds: 30)
- } catch is StoreSyncError {
- purchaseError = .restoreSyncTimeout
- return false
- } catch {
- purchaseError = .generic
- return false
- }
- await reconcilePremiumAccess(trustedPlan: nil)
- await syncPurchaseHistory(allowReset: true)
- if !isPremium {
- purchaseError = .noActivePurchases
- }
- return isPremium
- }
- func showAlert(title: String, message: String, on window: NSWindow?) {
- let alert = NSAlert()
- alert.messageText = title
- alert.informativeText = message
- alert.alertStyle = .informational
- alert.addButton(withTitle: PaywallConfigService.shared.config.messages.alertOK)
- if let window {
- alert.beginSheetModal(for: window)
- } else {
- alert.runModal()
- }
- }
- func showManageSubscriptions() {
- let urlString = PaywallConfigService.shared.config.urls.manageSubscriptions
- guard let url = URL(string: urlString) else { return }
- NSWorkspace.shared.open(url)
- }
- func showPurchaseError(_ error: PurchaseError, on window: NSWindow?) {
- let config = PaywallConfigService.shared.config
- showAlert(
- title: config.messages.purchaseFailedTitle,
- message: config.message(for: error),
- on: window
- )
- }
- private func performLoadProducts(presentLoadingUI: Bool?) async {
- let shouldReload = presentLoadingUI == true || productsByID.isEmpty
- let shouldShowLoading = presentLoadingUI ?? productsByID.isEmpty
- if !shouldReload, presentLoadingUI != true {
- purchaseError = nil
- await refreshPremiumAccess()
- await refreshTrialOffers()
- postStoreStateDidChange()
- return
- }
- if shouldShowLoading {
- isLoadingProducts = true
- postStoreStateDidChange()
- }
- defer {
- if shouldShowLoading {
- isLoadingProducts = false
- postStoreStateDidChange()
- }
- }
- purchaseError = nil
- productLoadError = nil
- do {
- let loaded = try await loadProductsWithTimeout(seconds: 12)
- guard !loaded.isEmpty else {
- productLoadError = PaywallConfigService.shared.config.messages.noPlansAvailable
- productsByID = [:]
- await refreshTrialOffers()
- return
- }
- var map: [String: Product] = [:]
- for product in loaded {
- map[product.id] = product
- }
- productsByID = map
- productLoadError = nil
- NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
- await refreshPremiumAccess()
- await refreshTrialOffers()
- } catch is ProductLoadError {
- productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
- purchaseError = .productsLoadTimeout
- await refreshTrialOffers()
- } catch {
- productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
- purchaseError = .generic
- await refreshTrialOffers()
- }
- }
- private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] {
- try await withThrowingTaskGroup(of: [Product].self) { group in
- group.addTask {
- try await Product.products(for: StoreProductID.all)
- }
- group.addTask {
- let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
- try await Task.sleep(nanoseconds: timeoutNanoseconds)
- throw ProductLoadError.timeout
- }
- guard let first = try await group.next() else {
- throw ProductLoadError.timeout
- }
- group.cancelAll()
- return first
- }
- }
- private func refreshPremiumAccess(trustedPlan: PaywallPlan? = nil) async {
- var resolvedPlan: PaywallPlan?
- if let lifetimePlan = await activeLifetimePlan() {
- resolvedPlan = lifetimePlan
- }
- if resolvedPlan == nil {
- for await result in Transaction.currentEntitlements {
- guard case .verified(let transaction) = result else { continue }
- guard let plan = planGrantingPremium(from: transaction) else { continue }
- resolvedPlan = preferredPlan(resolvedPlan, plan)
- }
- }
- if resolvedPlan == nil || resolvedPlan != .lifetime {
- if let subscriptionPlan = await activeSubscriptionPlan() {
- resolvedPlan = preferredPlan(resolvedPlan, subscriptionPlan)
- }
- }
- if resolvedPlan == nil {
- if let statusPlan = await activePlanFromSubscriptionStatus() {
- resolvedPlan = statusPlan
- }
- }
- let finalPlan = mergeTrustedPlan(resolvedPlan, trustedPlan)
- applyPremiumStatus(plan: finalPlan)
- hasResolvedPremiumStatus = true
- NotificationCenter.default.post(name: .entitlementsDidResolve, object: nil)
- await syncPurchaseHistory(allowReset: false)
- await refreshTrialOffers()
- }
- private func reconcilePremiumAccess(trustedPlan: PaywallPlan?) async {
- await refreshPremiumAccess(trustedPlan: trustedPlan)
- guard let trustedPlan, !isPremium else { return }
- do {
- try await syncAppStoreWithTimeout(seconds: 15)
- } catch {
- applyTrustedPlan(trustedPlan)
- return
- }
- await refreshPremiumAccess(trustedPlan: trustedPlan)
- if !isPremium {
- applyTrustedPlan(trustedPlan)
- }
- }
- private func mergeTrustedPlan(_ resolvedPlan: PaywallPlan?, _ trustedPlan: PaywallPlan?) -> PaywallPlan? {
- switch (resolvedPlan, trustedPlan) {
- case (nil, nil):
- nil
- case (let resolved?, nil):
- resolved
- case (nil, let trusted?):
- trusted
- case (let resolved?, let trusted?):
- preferredPlan(resolved, trusted)
- }
- }
- private func applyTrustedPlan(_ plan: PaywallPlan) {
- activePremiumPlan = preferredPlan(activePremiumPlan, plan)
- applyPremiumStatus(plan: activePremiumPlan)
- hasResolvedPremiumStatus = true
- markAsHavingPurchasedPremium()
- }
- private func revokePremiumAccess() {
- applyPremiumStatus(plan: nil)
- hasResolvedPremiumStatus = true
- trialDisplayByPlan = [:]
- }
- private func syncAppStoreWithTimeout(seconds: TimeInterval) async throws {
- try await withThrowingTaskGroup(of: Void.self) { group in
- group.addTask {
- try await AppStore.sync()
- }
- group.addTask {
- let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
- try await Task.sleep(nanoseconds: timeoutNanoseconds)
- throw StoreSyncError.timeout
- }
- try await group.next()
- group.cancelAll()
- }
- }
- private func applyPremiumAccess(from transaction: StoreKit.Transaction) {
- guard let plan = planGrantingPremium(from: transaction) else { return }
- activePremiumPlan = preferredPlan(activePremiumPlan, plan)
- applyPremiumStatus(plan: activePremiumPlan)
- hasResolvedPremiumStatus = true
- markAsHavingPurchasedPremium()
- }
- private func markAsHavingPurchasedPremium() {
- guard !hasEverPurchasedPremium else { return }
- hasEverPurchasedPremium = true
- trialDisplayByPlan = [:]
- UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
- }
- private func syncPurchaseHistory(allowReset: Bool) async {
- var hasRecordedPurchase = false
- for await result in Transaction.all {
- guard case .verified(let transaction) = result else { continue }
- guard StoreProductID.all.contains(transaction.productID) else { continue }
- hasRecordedPurchase = true
- break
- }
- if hasRecordedPurchase {
- if !hasEverPurchasedPremium {
- hasEverPurchasedPremium = true
- UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
- }
- } else if allowReset, hasEverPurchasedPremium {
- resetToNeverPurchasedState()
- }
- }
- private func resetToNeverPurchasedState() {
- hasEverPurchasedPremium = false
- UserDefaults.standard.set(false, forKey: Self.cachedHasEverPurchasedKey)
- trialDisplayByPlan = [:]
- }
- private func refreshTrialOffers() async {
- guard !isPremium else {
- trialDisplayByPlan = [:]
- postStoreStateDidChange()
- return
- }
- guard let eligiblePlan = PaywallConfigService.shared.config.trialEligiblePlan else {
- trialDisplayByPlan = [:]
- postStoreStateDidChange()
- return
- }
- if let display = await resolveTrialDisplay(for: eligiblePlan) {
- trialDisplayByPlan = [eligiblePlan: display]
- } else {
- trialDisplayByPlan = [:]
- }
- postStoreStateDidChange()
- }
- private func resolveTrialDisplay(for plan: PaywallPlan) async -> PaywallTrialDisplay? {
- guard plan == PaywallConfigService.shared.config.trialEligiblePlan else { return nil }
- guard !isPremium else { return nil }
- let config = PaywallConfigService.shared.config
- guard let product = product(for: plan),
- let subscription = product.subscription else {
- return fallbackTrialDisplay(config: config)
- }
- guard await subscription.isEligibleForIntroOffer else { return nil }
- if let offer = subscription.introductoryOffer {
- return PaywallTrialDisplay.from(offer: offer, config: config)
- }
- return fallbackTrialDisplay(config: config)
- }
- private func fallbackTrialDisplay(config: PaywallConfig) -> PaywallTrialDisplay? {
- guard !isPremium else { return nil }
- guard let fallback = config.trial.fallbackDuration else { return nil }
- return PaywallTrialDisplay.from(fallback: fallback, config: config)
- }
- private func applyPremiumStatus(plan: PaywallPlan?) {
- let hasPremium = plan != nil
- let kind: PremiumAccessKind
- switch plan {
- case .lifetime: kind = .lifetime
- case .monthly, .yearly: kind = .subscription
- case nil: kind = .none
- }
- let previousKind = premiumAccessKind
- let previousPlan = activePremiumPlan
- let didChange = hasPremium != isPremium || kind != previousKind || plan != previousPlan
- isPremium = hasPremium
- activePremiumPlan = plan
- UserDefaults.standard.set(hasPremium, forKey: Self.cachedPremiumKey)
- if let plan {
- UserDefaults.standard.set(plan.rawValue, forKey: Self.cachedPremiumPlanKey)
- } else {
- UserDefaults.standard.removeObject(forKey: Self.cachedPremiumPlanKey)
- }
- if didChange {
- NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
- }
- postStoreStateDidChange()
- }
- private func planGrantingPremium(from transaction: StoreKit.Transaction) -> PaywallPlan? {
- guard transactionGrantsPremium(transaction) else { return nil }
- return StoreProductID.plan(for: transaction.productID)
- }
- private func transactionGrantsPremium(_ transaction: StoreKit.Transaction) -> Bool {
- guard StoreProductID.all.contains(transaction.productID) else { return false }
- guard transaction.revocationDate == nil else { return false }
- if let expirationDate = transaction.expirationDate, expirationDate < Date() {
- return false
- }
- return true
- }
- private func preferredPlan(_ current: PaywallPlan?, _ candidate: PaywallPlan) -> PaywallPlan {
- if current == .lifetime || candidate == .lifetime {
- return .lifetime
- }
- if current == .yearly || candidate == .yearly {
- return .yearly
- }
- if current == .monthly || candidate == .monthly {
- return .monthly
- }
- return candidate
- }
- private func activeLifetimePlan() async -> PaywallPlan? {
- guard let result = await Transaction.latest(for: StoreProductID.lifetime) else {
- return nil
- }
- guard case .verified(let transaction) = result else { return nil }
- return planGrantingPremium(from: transaction)
- }
- private func activeSubscriptionPlan() async -> PaywallPlan? {
- var resolvedPlan: PaywallPlan?
- for await result in Transaction.currentEntitlements {
- guard case .verified(let transaction) = result else { continue }
- guard let plan = planGrantingPremium(from: transaction), plan != .lifetime else { continue }
- resolvedPlan = preferredPlan(resolvedPlan, plan)
- }
- return resolvedPlan
- }
- private func activePlanFromSubscriptionStatus() async -> PaywallPlan? {
- let productIDs = [StoreProductID.monthly, StoreProductID.yearly]
- for productID in productIDs {
- let product: Product?
- if let cached = productsByID[productID] {
- product = cached
- } else {
- product = try? await Product.products(for: [productID]).first
- }
- guard let product,
- let subscription = product.subscription,
- let statuses = try? await subscription.status else {
- continue
- }
- for status in statuses {
- switch status.state {
- case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
- return StoreProductID.plan(for: productID)
- default:
- continue
- }
- }
- }
- return nil
- }
- private func listenForTransactionUpdates() async {
- for await update in Transaction.updates {
- guard case .verified(let transaction) = update else { continue }
- if isRevokedAppTransaction(transaction) {
- revokePremiumAccess()
- await transaction.finish()
- await refreshPremiumAccess()
- continue
- }
- let trustedPlan = planGrantingPremium(from: transaction)
- if let trustedPlan {
- applyPremiumAccess(from: transaction)
- }
- await transaction.finish()
- if trustedPlan != nil {
- await reconcilePremiumAccess(trustedPlan: trustedPlan)
- } else {
- await refreshPremiumAccess()
- }
- }
- }
- private func isRevokedAppTransaction(_ transaction: StoreKit.Transaction) -> Bool {
- StoreProductID.all.contains(transaction.productID) && transaction.revocationDate != nil
- }
- private func postStoreStateDidChange() {
- NotificationCenter.default.post(name: .storeStateDidChange, object: nil)
- }
- }
- extension Notification.Name {
- static let premiumStatusDidChange = Notification.Name("premiumStatusDidChange")
- static let entitlementsDidResolve = Notification.Name("entitlementsDidResolve")
- static let storeProductsDidUpdate = Notification.Name("storeProductsDidUpdate")
- static let storeStateDidChange = Notification.Name("storeStateDidChange")
- }
- // MARK: - Premium Access
- enum PremiumAccess {
- @MainActor
- static func canAccess(_ feature: AppFeature) -> Bool {
- let store = StoreManager.shared
- return FreeTierManager.canAccess(feature, isPremium: store.isPremium)
- }
- @MainActor
- @discardableResult
- static func require(
- feature: AppFeature,
- from window: NSWindow?,
- returnToHomeOnDismiss: Bool = false
- ) -> Bool {
- guard canAccess(feature) else {
- presentPaywallOrLimitAlert(for: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
- return false
- }
- return true
- }
- @MainActor
- @discardableResult
- static func requireForAction(
- feature: AppFeature,
- from window: NSWindow?,
- returnToHomeOnDismiss: Bool = false,
- recordFreeUse: Bool = true
- ) -> Bool {
- guard require(feature: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss) else {
- return false
- }
- if recordFreeUse {
- recordFreeUseIfNeeded()
- }
- return true
- }
- @MainActor
- static func recordFreeUseIfNeeded() {
- guard !StoreManager.shared.isPremium else { return }
- FreeTierManager.recordUseIfNeeded(isPremium: false)
- }
- @MainActor
- @discardableResult
- static func require(from window: NSWindow?, returnToHomeOnDismiss: Bool = false) -> Bool {
- require(feature: .documentImport, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
- }
- @MainActor
- private static func presentPaywallOrLimitAlert(
- for feature: AppFeature,
- from window: NSWindow?,
- returnToHomeOnDismiss: Bool
- ) {
- if StoreManager.shared.isResolvingEntitlements {
- Task { @MainActor in
- await StoreManager.shared.ensureEntitlementsResolved()
- guard !canAccess(feature) else { return }
- presentPaywallOrLimitAlert(for: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
- }
- return
- }
- guard let viewController = mainViewController(from: window) else { return }
- viewController.presentPaywall(returnToHomeOnDismiss: returnToHomeOnDismiss)
- }
- @MainActor
- static func mainViewController(from window: NSWindow?) -> ViewController? {
- if let viewController = window?.contentViewController as? ViewController {
- return viewController
- }
- if let viewController = NSApp.keyWindow?.contentViewController as? ViewController {
- return viewController
- }
- for appWindow in NSApp.windows where appWindow.canBecomeMain {
- if let viewController = appWindow.contentViewController as? ViewController {
- return viewController
- }
- }
- return nil
- }
- }
- // MARK: - Left Panel
- private final class PaywallLeftPanelView: NSView, AppearanceRefreshable {
- private let gradientLayer = CAGradientLayer()
- override init(frame frameRect: NSRect) {
- super.init(frame: frameRect)
- wantsLayer = true
- gradientLayer.startPoint = CGPoint(x: 0.5, y: 1)
- gradientLayer.endPoint = CGPoint(x: 0.5, y: 0)
- layer?.insertSublayer(gradientLayer, at: 0)
- refreshAppearance()
- }
- func refreshAppearance() {
- gradientLayer.colors = AppTheme.paywallLeftGradientColors.map(\.cgColor)
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- override func layout() {
- super.layout()
- gradientLayer.frame = bounds
- let mask = CAShapeLayer()
- mask.path = CGPath(
- roundedRect: bounds,
- cornerWidth: 20,
- cornerHeight: 20,
- transform: nil
- )
- layer?.mask = mask
- }
- }
- // MARK: - Badge
- private final class PaywallBadgeView: NSView {
- private let label: NSTextField
- init(text: String, iconName: String, background: NSColor, foreground: NSColor) {
- label = NSTextField(labelWithString: text)
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- layer?.backgroundColor = background.cgColor
- layer?.cornerRadius = 10
- layer?.masksToBounds = true
- let icon = NSImageView()
- icon.translatesAutoresizingMaskIntoConstraints = false
- if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
- let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .semibold)
- icon.image = image.withSymbolConfiguration(config)
- }
- icon.contentTintColor = foreground
- label.font = AppTheme.semiboldFont(size: 10)
- label.textColor = foreground
- label.translatesAutoresizingMaskIntoConstraints = false
- addSubview(icon)
- addSubview(label)
- NSLayoutConstraint.activate([
- heightAnchor.constraint(equalToConstant: 20),
- icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
- icon.centerYAnchor.constraint(equalTo: centerYAnchor),
- icon.widthAnchor.constraint(equalToConstant: 12),
- icon.heightAnchor.constraint(equalToConstant: 12),
- label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 4),
- label.centerYAnchor.constraint(equalTo: centerYAnchor),
- label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
- ])
- }
- func updateText(_ text: String) {
- label.stringValue = text
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- }
- // MARK: - Feature Row
- private final class PaywallFeatureRow: NSView, AppearanceRefreshable {
- private let label: NSTextField
- init(text: String) {
- label = NSTextField.themeLabel(text, style: .primary, font: AppTheme.regularFont(size: 14))
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- let checkContainer = NSView()
- checkContainer.translatesAutoresizingMaskIntoConstraints = false
- checkContainer.wantsLayer = true
- checkContainer.layer?.backgroundColor = AppTheme.green.cgColor
- checkContainer.layer?.cornerRadius = 10
- checkContainer.layer?.masksToBounds = true
- let checkIcon = NSImageView()
- checkIcon.translatesAutoresizingMaskIntoConstraints = false
- if let image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil) {
- let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .bold)
- checkIcon.image = image.withSymbolConfiguration(config)
- }
- checkIcon.contentTintColor = .white
- label.translatesAutoresizingMaskIntoConstraints = false
- addSubview(checkContainer)
- checkContainer.addSubview(checkIcon)
- addSubview(label)
- NSLayoutConstraint.activate([
- heightAnchor.constraint(equalToConstant: 28),
- checkContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
- checkContainer.centerYAnchor.constraint(equalTo: centerYAnchor),
- checkContainer.widthAnchor.constraint(equalToConstant: 20),
- checkContainer.heightAnchor.constraint(equalToConstant: 20),
- checkIcon.centerXAnchor.constraint(equalTo: checkContainer.centerXAnchor),
- checkIcon.centerYAnchor.constraint(equalTo: checkContainer.centerYAnchor),
- checkIcon.widthAnchor.constraint(equalToConstant: 12),
- checkIcon.heightAnchor.constraint(equalToConstant: 12),
- label.leadingAnchor.constraint(equalTo: checkContainer.trailingAnchor, constant: 12),
- label.centerYAnchor.constraint(equalTo: centerYAnchor),
- label.trailingAnchor.constraint(equalTo: trailingAnchor),
- ])
- }
- func updateText(_ text: String) {
- label.stringValue = text
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- label.refreshThemeLabelColor()
- }
- }
- // MARK: - Plan Card
- private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
- var onSelect: (() -> Void)?
- private let plan: PaywallPlan
- private let titleLabel = NSTextField(labelWithString: "")
- private let subtitleLabel = NSTextField(labelWithString: "")
- private let priceLabel = NSTextField(labelWithString: "")
- private var trialBadgeView: PaywallBadgeView?
- private var hoverTracker: HoverTracker?
- private var isHovered = false
- var isChosen: Bool = false {
- didSet { updateAppearance() }
- }
- init(plan: PaywallPlan) {
- self.plan = plan
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- layer?.cornerRadius = 12
- layer?.masksToBounds = false
- let config = PaywallConfigService.shared.config
- titleLabel.stringValue = plan.planCopy(from: config).title
- titleLabel.font = AppTheme.semiboldFont(size: 15)
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- subtitleLabel.stringValue = config.loadingPrice
- subtitleLabel.font = AppTheme.regularFont(size: 11)
- subtitleLabel.themeLabelStyle = .secondary
- subtitleLabel.textColor = AppTheme.textSecondary
- subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
- priceLabel.stringValue = config.loadingPrice
- priceLabel.font = AppTheme.semiboldFont(size: 15)
- priceLabel.alignment = .right
- priceLabel.translatesAutoresizingMaskIntoConstraints = false
- addSubview(titleLabel)
- addSubview(subtitleLabel)
- addSubview(priceLabel)
- NSLayoutConstraint.activate([
- heightAnchor.constraint(equalToConstant: 86),
- titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
- titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 24),
- subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
- subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 3),
- subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: priceLabel.leadingAnchor, constant: -12),
- priceLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
- priceLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- ])
- applyCardShadow()
- updateAppearance()
- hoverTracker = HoverTracker(view: self) { [weak self] hovering in
- self?.setHovered(hovering)
- }
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func updateDisplay(
- product: Product?,
- config: PaywallConfig,
- trial: PaywallTrialDisplay? = nil
- ) {
- titleLabel.stringValue = plan.planCopy(from: config).title
- subtitleLabel.stringValue = plan.localizedSubtitle(from: product, config: config, trial: trial)
- priceLabel.stringValue = plan.localizedPrice(from: product, config: config)
- updateTrialBadge(trial: trial)
- }
- private func updateTrialBadge(trial: PaywallTrialDisplay?) {
- guard plan == PaywallConfigService.shared.config.trialEligiblePlan else { return }
- if let trial {
- let text = trial.badgeText
- if let trialBadgeView {
- trialBadgeView.updateText(text)
- trialBadgeView.isHidden = false
- } else {
- let badge = PaywallBadgeView(
- text: text,
- iconName: "calendar",
- background: AppTheme.paywallPink,
- foreground: AppTheme.paywallPinkText
- )
- trialBadgeView = badge
- addSubview(badge, positioned: .above, relativeTo: nil)
- NSLayoutConstraint.activate([
- badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
- badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
- ])
- }
- trialBadgeView?.layer?.zPosition = 10
- } else {
- trialBadgeView?.isHidden = true
- }
- }
- func refreshAppearance() {
- updateAppearance()
- subtitleLabel.refreshThemeLabelColor()
- if isHovered {
- applyHoverLift(true)
- }
- }
- private func setHovered(_ hovering: Bool) {
- isHovered = hovering
- applyHoverLift(hovering)
- updateAppearance()
- }
- private func updateAppearance() {
- let trialPlan = PaywallConfigService.shared.config.trialEligiblePlan
- let titleColor = isChosen && plan == trialPlan ? AppTheme.green : AppTheme.paywallAccent
- titleLabel.textColor = titleColor
- priceLabel.textColor = titleColor
- layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
- if isChosen {
- layer?.borderWidth = 2
- layer?.borderColor = AppTheme.green.cgColor
- } else if isHovered {
- layer?.borderWidth = 2
- let hoverBorder = AppTheme.paywallBorder.blended(withFraction: 0.35, of: AppTheme.paywallAccent)
- ?? AppTheme.paywallBorder
- layer?.borderColor = hoverBorder.cgColor
- } else {
- layer?.borderWidth = 1.5
- layer?.borderColor = AppTheme.paywallBorder.cgColor
- }
- }
- override func mouseUp(with event: NSEvent) {
- guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onSelect?()
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- // MARK: - Footer Link
- private final class PaywallFooterLink: NSButton, AppearanceRefreshable {
- private var hoverTracker: HoverTracker?
- private var isHovered = false
- init(title: String) {
- super.init(frame: .zero)
- updateTitle(title)
- isBordered = false
- bezelStyle = .inline
- font = AppTheme.regularFont(size: 11)
- translatesAutoresizingMaskIntoConstraints = false
- refreshAppearance()
- hoverTracker = HoverTracker(view: self) { [weak self] hovering in
- self?.isHovered = hovering
- self?.refreshAppearance()
- }
- }
- @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
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- // MARK: - Footer Trust Item
- private final class PaywallTrustItemView: NSView, AppearanceRefreshable {
- private let iconContainer = NSView()
- private let icon = NSImageView()
- private let titleLabel: NSTextField
- private let subtitleLabel: NSTextField
- init(iconName: String, title: String, subtitle: String) {
- titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.semiboldFont(size: 11))
- subtitleLabel = NSTextField.themeLabel(subtitle, style: .secondary, font: AppTheme.regularFont(size: 9))
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- iconContainer.translatesAutoresizingMaskIntoConstraints = false
- iconContainer.wantsLayer = true
- iconContainer.layer?.cornerRadius = 10
- iconContainer.layer?.masksToBounds = true
- icon.translatesAutoresizingMaskIntoConstraints = false
- if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
- let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
- icon.image = image.withSymbolConfiguration(config)
- }
- titleLabel.lineBreakMode = .byTruncatingTail
- titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- subtitleLabel.lineBreakMode = .byTruncatingTail
- subtitleLabel.maximumNumberOfLines = 2
- subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
- addSubview(iconContainer)
- iconContainer.addSubview(icon)
- addSubview(titleLabel)
- addSubview(subtitleLabel)
- NSLayoutConstraint.activate([
- heightAnchor.constraint(equalToConstant: Self.preferredHeight),
- iconContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
- iconContainer.topAnchor.constraint(equalTo: topAnchor),
- iconContainer.widthAnchor.constraint(equalToConstant: 20),
- iconContainer.heightAnchor.constraint(equalToConstant: 20),
- icon.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
- icon.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
- icon.widthAnchor.constraint(equalToConstant: 12),
- icon.heightAnchor.constraint(equalToConstant: 12),
- titleLabel.leadingAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: 8),
- titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 1),
- titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
- subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
- subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2),
- subtitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
- subtitleLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
- ])
- setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- setContentHuggingPriority(.defaultLow, for: .horizontal)
- setContentHuggingPriority(.required, for: .vertical)
- setContentCompressionResistancePriority(.required, for: .vertical)
- refreshAppearance()
- }
- fileprivate static let preferredHeight: CGFloat = 34
- func update(iconName: String, title: String, subtitle: String) {
- titleLabel.stringValue = title
- subtitleLabel.stringValue = subtitle
- if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
- let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
- icon.image = image.withSymbolConfiguration(symbolConfig)
- }
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- iconContainer.layer?.backgroundColor = AppTheme.paywallTrustIconBackground.cgColor
- icon.contentTintColor = AppTheme.paywallIconAccent
- titleLabel.refreshThemeLabelColor()
- subtitleLabel.refreshThemeLabelColor()
- }
- }
- // MARK: - Products Error
- private final class PaywallProductsErrorView: NSView, AppearanceRefreshable {
- var onRetry: (() -> Void)?
- private let messageLabel = NSTextField(wrappingLabelWithString: "")
- private let retryButton = PaywallFooterLink(title: "")
- init() {
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- messageLabel.font = AppTheme.regularFont(size: 12)
- messageLabel.textColor = AppTheme.textSecondary
- messageLabel.alignment = .center
- messageLabel.maximumNumberOfLines = 3
- messageLabel.lineBreakMode = .byWordWrapping
- messageLabel.cell?.wraps = true
- messageLabel.cell?.isScrollable = false
- messageLabel.translatesAutoresizingMaskIntoConstraints = false
- retryButton.target = self
- retryButton.action = #selector(retryTapped)
- addSubview(messageLabel)
- addSubview(retryButton)
- NSLayoutConstraint.activate([
- messageLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
- messageLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
- messageLabel.topAnchor.constraint(equalTo: topAnchor),
- retryButton.centerXAnchor.constraint(equalTo: centerXAnchor),
- retryButton.topAnchor.constraint(equalTo: messageLabel.bottomAnchor, constant: 8),
- retryButton.bottomAnchor.constraint(equalTo: bottomAnchor),
- ])
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func update(message: String, isRetrying: Bool) {
- let messages = PaywallConfigService.shared.config.messages
- messageLabel.stringValue = message
- retryButton.isHidden = false
- retryButton.isEnabled = !isRetrying
- retryButton.updateTitle(isRetrying ? messages.retrying : messages.retry)
- }
- func refreshAppearance() {
- messageLabel.textColor = AppTheme.textSecondary
- retryButton.refreshAppearance()
- }
- @objc private func retryTapped() {
- onRetry?()
- }
- }
- // MARK: - Close Button
- private final class PaywallCloseButton: NSButton, AppearanceRefreshable {
- private var hoverTracker: HoverTracker?
- private var isHovered = false
- init() {
- super.init(frame: .zero)
- isBordered = false
- bezelStyle = .inline
- translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- layer?.cornerRadius = 15
- layer?.borderWidth = 1.5
- if let image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close") {
- let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
- self.image = image.withSymbolConfiguration(config)
- }
- refreshAppearance()
- hoverTracker = HoverTracker(view: self) { [weak self] hovering in
- self?.setHovered(hovering)
- }
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- updateAppearance()
- }
- private func setHovered(_ hovering: Bool) {
- isHovered = hovering
- animateHover {
- updateAppearance()
- layer?.transform = hovering
- ? CATransform3DMakeScale(1.06, 1.06, 1)
- : CATransform3DIdentity
- }
- }
- private func updateAppearance() {
- if isHovered {
- layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
- layer?.borderColor = AppTheme.paywallAccent.withAlphaComponent(0.45).cgColor
- contentTintColor = AppTheme.textPrimary
- } else {
- layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
- layer?.borderColor = AppTheme.paywallBorder.cgColor
- contentTintColor = AppTheme.textSecondary
- }
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- // MARK: - CTA Button
- private final class PaywallCTAButton: NSButton, AppearanceRefreshable {
- private var hoverTracker: HoverTracker?
- private let progressIndicator = NSProgressIndicator()
- init() {
- super.init(frame: .zero)
- isBordered = false
- wantsLayer = true
- layer?.cornerRadius = 12
- font = AppTheme.semiboldFont(size: 15)
- translatesAutoresizingMaskIntoConstraints = false
- progressIndicator.style = .spinning
- progressIndicator.controlSize = .small
- progressIndicator.isDisplayedWhenStopped = false
- progressIndicator.translatesAutoresizingMaskIntoConstraints = false
- progressIndicator.isHidden = true
- addSubview(progressIndicator)
- NSLayoutConstraint.activate([
- progressIndicator.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
- progressIndicator.centerYAnchor.constraint(equalTo: centerYAnchor),
- ])
- refreshAppearance()
- hoverTracker = HoverTracker(view: self) { [weak self] hovering in
- self?.setHovered(hovering)
- }
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func setShowsProgress(_ showsProgress: Bool) {
- progressIndicator.isHidden = !showsProgress
- if showsProgress {
- progressIndicator.startAnimation(nil)
- } else {
- progressIndicator.stopAnimation(nil)
- }
- }
- func refreshAppearance() {
- layer?.backgroundColor = AppTheme.paywallCTABackground.cgColor
- contentTintColor = AppTheme.paywallCTAForeground
- }
- private func setHovered(_ hovering: Bool) {
- let base = AppTheme.paywallCTABackground
- let color = hovering ? base.blended(withFraction: 0.12, of: .black) ?? base : base
- animateHover {
- layer?.backgroundColor = color.cgColor
- layer?.transform = hovering
- ? CATransform3DMakeScale(1.02, 1.02, 1)
- : CATransform3DIdentity
- }
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- // MARK: - Main Paywall Card
- final class PaywallView: NSView, AppearanceRefreshable {
- var onClose: (() -> Void)?
- private var selectedPlan: PaywallPlan = .yearly
- private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
- private let ctaButton = PaywallCTAButton()
- private let renewalDisclosureLabel = NSTextField(wrappingLabelWithString: "")
- private let securePaymentLabel = NSTextField(labelWithString: "")
- private let continueFreePlanLink = PaywallFooterLink(title: "")
- private let manageSubscriptionLink = PaywallFooterLink(title: "")
- private let restoreLink = PaywallFooterLink(title: "")
- private let privacyLink = PaywallFooterLink(title: "")
- private let termsLink = PaywallFooterLink(title: "")
- private let supportLink = PaywallFooterLink(title: "")
- private let premiumCloseButton = PaywallCloseButton()
- private var primaryFooterLinkCell: NSView?
- private var leftPanelTitle: NSTextField!
- private var rightTitle: NSTextField!
- private var rightSubtitle: NSTextField!
- private var featuresStack: NSStackView!
- private var featureRows: [PaywallFeatureRow] = []
- private var trustItemViews: [PaywallTrustItemView] = []
- private var trustStack: NSStackView!
- private var productsErrorView: PaywallProductsErrorView!
- private var plansStack: NSStackView!
- private var plansStackHeightConstraint: NSLayoutConstraint!
- private var plansAreaViewHeightConstraint: NSLayoutConstraint!
- private var productsErrorHeightConstraint: NSLayoutConstraint!
- private var storeObservers: [NSObjectProtocol] = []
- init() {
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- layer?.cornerRadius = 0
- setup()
- observeStoreUpdates()
- refreshConfigDisplay()
- refreshProductDisplay()
- refreshAppearance()
- }
- deinit {
- storeObservers.forEach { NotificationCenter.default.removeObserver($0) }
- }
- private func observeStoreUpdates() {
- let center = NotificationCenter.default
- storeObservers = [
- center.addObserver(forName: .storeProductsDidUpdate, object: nil, queue: .main) { [weak self] _ in
- self?.refreshProductDisplay()
- },
- center.addObserver(forName: .storeStateDidChange, object: nil, queue: .main) { [weak self] _ in
- self?.refreshProductDisplay()
- self?.selectTrialEligiblePlanIfAvailable()
- },
- center.addObserver(forName: .premiumStatusDidChange, object: nil, queue: .main) { [weak self] _ in
- self?.refreshProductDisplay()
- },
- center.addObserver(forName: .paywallConfigDidUpdate, object: nil, queue: .main) { [weak self] _ in
- self?.refreshConfigDisplay()
- self?.refreshProductDisplay()
- },
- ]
- }
- private func selectTrialEligiblePlanIfAvailable() {
- let store = StoreManager.shared
- let config = paywallConfig
- guard let eligiblePlan = config.trialEligiblePlan,
- store.trialDisplay(for: eligiblePlan) != nil else {
- return
- }
- selectPlan(eligiblePlan)
- }
- private var paywallConfig: PaywallConfig {
- PaywallConfigService.shared.config
- }
- private func refreshConfigDisplay() {
- let config = paywallConfig
- leftPanelTitle?.stringValue = config.leftPanelTitle
- rightTitle?.stringValue = config.rightTitle
- rightSubtitle?.stringValue = config.rightSubtitle
- syncFeatureRows(with: config.features)
- syncTrustItems(with: config.trustItems)
- continueFreePlanLink.updateTitle(config.footer.continueFree)
- manageSubscriptionLink.updateTitle(config.footer.manageSubscription)
- restoreLink.updateTitle(config.footer.restorePurchase)
- privacyLink.updateTitle(config.footer.privacyPolicy)
- termsLink.updateTitle(config.footer.termsOfService)
- supportLink.updateTitle(config.footer.support)
- refreshSecurePaymentNote()
- }
- private func syncFeatureRows(with features: [String]) {
- guard let featuresStack else { return }
- while featureRows.count < features.count {
- let row = PaywallFeatureRow(text: "")
- featureRows.append(row)
- featuresStack.addArrangedSubview(row)
- }
- while featureRows.count > features.count {
- let row = featureRows.removeLast()
- featuresStack.removeArrangedSubview(row)
- row.removeFromSuperview()
- }
- for (index, text) in features.enumerated() {
- featureRows[index].updateText(text)
- }
- }
- private func syncTrustItems(with items: [PaywallConfig.TrustItem]) {
- for (index, item) in items.enumerated() where index < trustItemViews.count {
- trustItemViews[index].update(
- iconName: item.icon,
- title: item.title,
- subtitle: item.subtitle
- )
- }
- }
- private func refreshProductDisplay() {
- let store = StoreManager.shared
- let config = paywallConfig
- let availablePlans = store.availablePlans
- syncPlanCards(with: availablePlans)
- for (plan, card) in planCards {
- let product = store.product(for: plan)
- card.updateDisplay(
- product: product,
- config: config,
- trial: store.trialDisplay(for: plan)
- )
- }
- ensureValidSelectedPlan(from: availablePlans)
- refreshPlansLayout(planCount: availablePlans.count)
- ctaButton.title = selectedPlan.localizedCTATitle(
- from: store.product(for: selectedPlan),
- config: config,
- trial: store.trialDisplay(for: selectedPlan)
- )
- updateRenewalDisclosure(
- selectedPlan.localizedRenewalDisclosure(
- from: store.product(for: selectedPlan),
- config: config,
- trial: store.trialDisplay(for: selectedPlan)
- )
- )
- refreshTrustItemsForSelectedPlan()
- refreshProductsErrorState()
- refreshPurchaseState()
- refreshSecurePaymentNote()
- selectTrialEligiblePlanIfAvailable()
- }
- private func syncPlanCards(with plans: [PaywallPlan]) {
- guard let plansStack else { return }
- let removedPlans = planCards.keys.filter { !plans.contains($0) }
- for plan in removedPlans {
- guard let card = planCards.removeValue(forKey: plan) else { continue }
- plansStack.removeArrangedSubview(card)
- card.removeFromSuperview()
- }
- for plan in plans where planCards[plan] == nil {
- let card = PaywallPlanCard(plan: plan)
- card.isChosen = plan == selectedPlan
- card.onSelect = { [weak self] in self?.selectPlan(plan) }
- planCards[plan] = card
- }
- for view in plansStack.arrangedSubviews {
- plansStack.removeArrangedSubview(view)
- }
- for plan in plans {
- if let card = planCards[plan] {
- plansStack.addArrangedSubview(card)
- }
- }
- }
- private func ensureValidSelectedPlan(from availablePlans: [PaywallPlan]) {
- guard !availablePlans.isEmpty else { return }
- guard !availablePlans.contains(selectedPlan) else { return }
- let preferred = paywallConfig.trialEligiblePlan
- .flatMap { trialPlan in
- availablePlans.contains(trialPlan) && StoreManager.shared.trialDisplay(for: trialPlan) != nil
- ? trialPlan
- : nil
- }
- ?? availablePlans.first(where: { $0 == .yearly })
- ?? availablePlans[0]
- selectPlan(preferred)
- }
- private func refreshPlansLayout(planCount: Int) {
- let height = plansAreaHeight(for: planCount)
- plansStackHeightConstraint?.constant = height
- plansAreaViewHeightConstraint?.constant = height
- }
- private func plansAreaHeight(for planCount: Int) -> CGFloat {
- let cardHeight: CGFloat = 86
- let spacing: CGFloat = 12
- let count = CGFloat(max(planCount, 0))
- guard count > 0 else { return 0 }
- return count * cardHeight + max(0, count - 1) * spacing
- }
- private func refreshSecurePaymentNote() {
- let config = paywallConfig
- securePaymentLabel.stringValue = selectedPlan == .lifetime
- ? config.lifetimeSecurePaymentNote
- : config.securePaymentNote
- }
- private func refreshProductsErrorState() {
- let store = StoreManager.shared
- let hasLoadFailure = store.productLoadError != nil && !store.hasAvailablePlans
- let isRetrying = hasLoadFailure && store.isLoadingProducts
- productsErrorView.isHidden = !hasLoadFailure
- plansStack.isHidden = hasLoadFailure || store.availablePlans.isEmpty
- let visiblePlanCount = hasLoadFailure ? 0 : store.availablePlans.count
- refreshPlansLayout(planCount: visiblePlanCount)
- productsErrorHeightConstraint.constant = hasLoadFailure ? productsErrorAreaHeight : 0
- if hasLoadFailure, let error = store.productLoadError {
- productsErrorView.update(message: error, isRetrying: isRetrying)
- }
- }
- private var productsErrorAreaHeight: CGFloat { 86 }
- private func refreshPurchaseState() {
- let store = StoreManager.shared
- let hasProductLoadFailure = store.productLoadError != nil && !store.hasAvailablePlans
- let isProductReady = store.product(for: selectedPlan) != nil
- let isBusy = store.isPurchasing || store.isLoadingProducts
- let isDisabled = isBusy || !isProductReady || hasProductLoadFailure
- ctaButton.isEnabled = !isDisabled
- ctaButton.alphaValue = isDisabled ? 0.65 : 1
- ctaButton.setShowsProgress(store.purchasingPlan == selectedPlan)
- restoreLink.isEnabled = !store.isRestoringPurchases
- restoreLink.updateTitle(
- store.isRestoringPurchases
- ? paywallConfig.messages.retrying
- : paywallConfig.footer.restorePurchase
- )
- refreshPrimaryFooterLink()
- }
- private func refreshPrimaryFooterLink() {
- let store = StoreManager.shared
- let showContinue = !store.isPro
- let showManage = store.hasActiveSubscription
- continueFreePlanLink.isHidden = !showContinue
- manageSubscriptionLink.isHidden = !showManage
- premiumCloseButton.isHidden = !store.isPro
- primaryFooterLinkCell?.isHidden = !showContinue && !showManage
- primaryFooterLinkCell?.needsLayout = true
- primaryFooterLinkCell?.layoutSubtreeIfNeeded()
- }
- private func refreshTrustItemsForSelectedPlan() {
- guard trustItemViews.count > 1, paywallConfig.trustItems.count > 1 else { return }
- let cancelItem = paywallConfig.trustItems[1]
- if selectedPlan == .lifetime {
- let item = paywallConfig.lifetimeTrustItem
- trustItemViews[1].update(
- iconName: item.icon,
- title: item.title,
- subtitle: item.subtitle
- )
- } else {
- trustItemViews[1].update(
- iconName: cancelItem.icon,
- title: cancelItem.title,
- subtitle: cancelItem.subtitle
- )
- }
- }
- func refreshStoreState() {
- refreshProductDisplay()
- }
- override func layout() {
- super.layout()
- let width = renewalDisclosureLabel.bounds.width
- guard width > 0, renewalDisclosureLabel.preferredMaxLayoutWidth != width else { return }
- renewalDisclosureLabel.preferredMaxLayoutWidth = width
- renewalDisclosureLabel.invalidateIntrinsicContentSize()
- }
- private func updateRenewalDisclosure(_ text: String) {
- renewalDisclosureLabel.stringValue = text
- renewalDisclosureLabel.invalidateIntrinsicContentSize()
- needsLayout = true
- }
- func refreshAppearance() {
- layer?.backgroundColor = AppTheme.paywallBackground.cgColor
- leftPanelTitle?.refreshThemeLabelColor()
- rightTitle?.refreshThemeLabelColor()
- rightSubtitle?.refreshThemeLabelColor()
- renewalDisclosureLabel.textColor = AppTheme.textSecondary
- securePaymentLabel.textColor = AppTheme.textSecondary
- trustStack?.layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
- trustStack?.layer?.borderColor = AppTheme.paywallBorder.cgColor
- ctaButton.refreshAppearance()
- subviews.forEach { $0.refreshAppearanceRecursively() }
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- private func setup() {
- let leftPanel = makeLeftPanel()
- let rightPanel = makeRightPanel()
- premiumCloseButton.target = self
- premiumCloseButton.action = #selector(premiumCloseTapped)
- premiumCloseButton.toolTip = paywallConfig.closeButtonHelp
- premiumCloseButton.isHidden = true
- addSubview(leftPanel)
- addSubview(rightPanel)
- addSubview(premiumCloseButton)
- NSLayoutConstraint.activate([
- leftPanel.leadingAnchor.constraint(equalTo: leadingAnchor),
- leftPanel.topAnchor.constraint(equalTo: topAnchor),
- leftPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
- leftPanel.widthAnchor.constraint(equalToConstant: 320),
- rightPanel.leadingAnchor.constraint(equalTo: leftPanel.trailingAnchor),
- rightPanel.trailingAnchor.constraint(equalTo: trailingAnchor),
- rightPanel.topAnchor.constraint(equalTo: topAnchor),
- rightPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
- premiumCloseButton.topAnchor.constraint(equalTo: topAnchor, constant: 16),
- premiumCloseButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
- premiumCloseButton.widthAnchor.constraint(equalToConstant: 30),
- premiumCloseButton.heightAnchor.constraint(equalToConstant: 30),
- ])
- }
- private func makeLeftPanel() -> NSView {
- let panel = PaywallLeftPanelView()
- panel.translatesAutoresizingMaskIntoConstraints = false
- let config = paywallConfig
- let title = NSTextField.themeLabel(
- config.leftPanelTitle,
- style: .primary,
- font: AppTheme.semiboldFont(size: 22)
- )
- title.maximumNumberOfLines = 2
- title.translatesAutoresizingMaskIntoConstraints = false
- leftPanelTitle = title
- let featuresStack = NSStackView()
- featuresStack.orientation = .vertical
- featuresStack.spacing = 6
- featuresStack.alignment = .leading
- featuresStack.translatesAutoresizingMaskIntoConstraints = false
- self.featuresStack = featuresStack
- for feature in config.features {
- let row = PaywallFeatureRow(text: feature)
- featureRows.append(row)
- featuresStack.addArrangedSubview(row)
- }
- panel.addSubview(title)
- panel.addSubview(featuresStack)
- NSLayoutConstraint.activate([
- title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
- title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
- title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 48),
- featuresStack.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- featuresStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
- featuresStack.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 28),
- ])
- return panel
- }
- private func makeRightPanel() -> NSView {
- let panel = NSView()
- panel.translatesAutoresizingMaskIntoConstraints = false
- let config = paywallConfig
- let title = NSTextField.themeLabel(
- config.rightTitle,
- style: .primary,
- font: AppTheme.semiboldFont(size: 26)
- )
- title.alignment = .center
- title.translatesAutoresizingMaskIntoConstraints = false
- rightTitle = title
- let subtitle = NSTextField.themeLabel(
- config.rightSubtitle,
- style: .secondary,
- font: AppTheme.regularFont(size: 13)
- )
- subtitle.alignment = .center
- subtitle.maximumNumberOfLines = 2
- subtitle.translatesAutoresizingMaskIntoConstraints = false
- rightSubtitle = subtitle
- let plansStack = NSStackView()
- plansStack.orientation = .vertical
- plansStack.spacing = 12
- plansStack.translatesAutoresizingMaskIntoConstraints = false
- self.plansStack = plansStack
- let plansAreaView = NSView()
- plansAreaView.translatesAutoresizingMaskIntoConstraints = false
- let productsErrorView = PaywallProductsErrorView()
- productsErrorView.isHidden = true
- productsErrorView.onRetry = { [weak self] in
- self?.retryProductLoad()
- }
- self.productsErrorView = productsErrorView
- ctaButton.title = selectedPlan.localizedCTATitle(
- from: StoreManager.shared.product(for: selectedPlan),
- config: config,
- trial: StoreManager.shared.trialDisplay(for: selectedPlan)
- )
- ctaButton.target = self
- ctaButton.action = #selector(purchaseTapped)
- ctaButton.translatesAutoresizingMaskIntoConstraints = false
- renewalDisclosureLabel.font = AppTheme.regularFont(size: 10)
- renewalDisclosureLabel.textColor = AppTheme.textSecondary
- renewalDisclosureLabel.alignment = .center
- renewalDisclosureLabel.maximumNumberOfLines = 3
- renewalDisclosureLabel.lineBreakMode = .byWordWrapping
- renewalDisclosureLabel.cell?.wraps = true
- renewalDisclosureLabel.cell?.isScrollable = false
- renewalDisclosureLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- renewalDisclosureLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
- renewalDisclosureLabel.setContentHuggingPriority(.required, for: .vertical)
- renewalDisclosureLabel.translatesAutoresizingMaskIntoConstraints = false
- updateRenewalDisclosure(
- selectedPlan.localizedRenewalDisclosure(
- from: StoreManager.shared.product(for: selectedPlan),
- config: config,
- trial: StoreManager.shared.trialDisplay(for: selectedPlan)
- )
- )
- securePaymentLabel.font = AppTheme.regularFont(size: 10)
- securePaymentLabel.textColor = AppTheme.textSecondary
- securePaymentLabel.alignment = .center
- securePaymentLabel.translatesAutoresizingMaskIntoConstraints = false
- refreshSecurePaymentNote()
- let trustRow = makeTrustRow()
- let footerLinks = makeFooterLinks()
- let footerSpacer = NSView()
- footerSpacer.translatesAutoresizingMaskIntoConstraints = false
- footerSpacer.setContentHuggingPriority(.defaultLow, for: .vertical)
- footerSpacer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
- plansAreaView.addSubview(plansStack)
- plansAreaView.addSubview(productsErrorView)
- plansStackHeightConstraint = plansStack.heightAnchor.constraint(equalToConstant: 0)
- productsErrorHeightConstraint = productsErrorView.heightAnchor.constraint(equalToConstant: 0)
- plansAreaViewHeightConstraint = plansAreaView.heightAnchor.constraint(equalToConstant: 0)
- panel.addSubview(title)
- panel.addSubview(subtitle)
- panel.addSubview(plansAreaView)
- panel.addSubview(trustRow)
- panel.addSubview(ctaButton)
- panel.addSubview(renewalDisclosureLabel)
- panel.addSubview(securePaymentLabel)
- panel.addSubview(footerSpacer)
- panel.addSubview(footerLinks)
- NSLayoutConstraint.activate([
- title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
- title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 40),
- title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
- subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- subtitle.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
- subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
- plansAreaView.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- plansAreaView.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
- plansAreaView.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 24),
- plansAreaViewHeightConstraint,
- plansStack.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
- plansStack.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
- plansStack.topAnchor.constraint(equalTo: plansAreaView.topAnchor),
- plansStackHeightConstraint,
- productsErrorView.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
- productsErrorView.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
- productsErrorView.topAnchor.constraint(equalTo: plansAreaView.topAnchor),
- productsErrorHeightConstraint,
- trustRow.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
- trustRow.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
- trustRow.topAnchor.constraint(equalTo: plansAreaView.bottomAnchor, constant: 18),
- ctaButton.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
- ctaButton.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
- ctaButton.topAnchor.constraint(equalTo: trustRow.bottomAnchor, constant: 16),
- ctaButton.heightAnchor.constraint(equalToConstant: 48),
- renewalDisclosureLabel.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
- renewalDisclosureLabel.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
- renewalDisclosureLabel.topAnchor.constraint(equalTo: ctaButton.bottomAnchor, constant: 10),
- securePaymentLabel.centerXAnchor.constraint(equalTo: plansAreaView.centerXAnchor),
- securePaymentLabel.topAnchor.constraint(equalTo: renewalDisclosureLabel.bottomAnchor, constant: 8),
- footerSpacer.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
- footerSpacer.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
- footerSpacer.topAnchor.constraint(equalTo: securePaymentLabel.bottomAnchor, constant: 16),
- footerSpacer.bottomAnchor.constraint(equalTo: footerLinks.topAnchor),
- footerLinks.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
- footerLinks.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
- footerLinks.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -16),
- ])
- return panel
- }
- private func makeTrustRow() -> NSView {
- let config = paywallConfig
- trustItemViews = config.trustItems.map { item in
- PaywallTrustItemView(
- iconName: item.icon,
- title: item.title,
- subtitle: item.subtitle
- )
- }
- let trustStack = NSStackView(views: trustItemViews)
- trustStack.orientation = .horizontal
- trustStack.distribution = .fillEqually
- trustStack.spacing = 16
- trustStack.alignment = .top
- trustStack.translatesAutoresizingMaskIntoConstraints = false
- trustStack.wantsLayer = true
- trustStack.layer?.cornerRadius = 12
- trustStack.layer?.borderWidth = 1.5
- trustStack.layer?.masksToBounds = true
- trustStack.edgeInsets = NSEdgeInsets(top: 12, left: 14, bottom: 12, right: 14)
- trustStack.translatesAutoresizingMaskIntoConstraints = false
- trustStack.setContentHuggingPriority(.required, for: .vertical)
- trustStack.setContentCompressionResistancePriority(.required, for: .vertical)
- trustStack.heightAnchor.constraint(
- equalToConstant: PaywallTrustItemView.preferredHeight + trustStack.edgeInsets.top + trustStack.edgeInsets.bottom
- ).isActive = true
- self.trustStack = trustStack
- return trustStack
- }
- private func makeFooterLinks() -> NSView {
- let container = NSView()
- container.translatesAutoresizingMaskIntoConstraints = false
- container.setContentHuggingPriority(.required, for: .vertical)
- container.setContentCompressionResistancePriority(.required, for: .vertical)
- let config = paywallConfig
- let separator = NSView()
- separator.translatesAutoresizingMaskIntoConstraints = false
- separator.wantsLayer = true
- separator.layer?.backgroundColor = AppTheme.paywallBorder.cgColor
- continueFreePlanLink.updateTitle(config.footer.continueFree)
- continueFreePlanLink.target = self
- continueFreePlanLink.action = #selector(continueWithFreePlanTapped)
- manageSubscriptionLink.updateTitle(config.footer.manageSubscription)
- manageSubscriptionLink.target = self
- manageSubscriptionLink.action = #selector(manageSubscriptionTapped)
- let primaryCell = makePrimaryFooterLinkCell()
- primaryFooterLinkCell = primaryCell
- refreshPrimaryFooterLink()
- restoreLink.updateTitle(config.footer.restorePurchase)
- restoreLink.target = self
- restoreLink.action = #selector(restoreTapped)
- privacyLink.updateTitle(config.footer.privacyPolicy)
- privacyLink.target = self
- privacyLink.action = #selector(privacyPolicyTapped)
- termsLink.updateTitle(config.footer.termsOfService)
- termsLink.target = self
- termsLink.action = #selector(termsOfServiceTapped)
- supportLink.updateTitle(config.footer.support)
- supportLink.target = self
- supportLink.action = #selector(supportTapped)
- let linkCells = [
- primaryCell,
- makeFooterLinkCell(link: restoreLink),
- makeFooterLinkCell(link: privacyLink),
- makeFooterLinkCell(link: termsLink),
- makeFooterLinkCell(link: supportLink),
- ]
- let linksStack = NSStackView(views: linkCells)
- linksStack.orientation = .horizontal
- linksStack.spacing = 0
- linksStack.alignment = .centerY
- linksStack.distribution = .fillEqually
- linksStack.translatesAutoresizingMaskIntoConstraints = false
- container.addSubview(separator)
- container.addSubview(linksStack)
- NSLayoutConstraint.activate([
- separator.leadingAnchor.constraint(equalTo: container.leadingAnchor),
- separator.trailingAnchor.constraint(equalTo: container.trailingAnchor),
- separator.topAnchor.constraint(equalTo: container.topAnchor),
- separator.heightAnchor.constraint(equalToConstant: 1),
- linksStack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
- linksStack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
- linksStack.topAnchor.constraint(equalTo: separator.bottomAnchor, constant: 10),
- linksStack.bottomAnchor.constraint(equalTo: container.bottomAnchor),
- ])
- return container
- }
- private func makePrimaryFooterLinkCell() -> NSView {
- let cell = NSView()
- cell.translatesAutoresizingMaskIntoConstraints = false
- cell.setContentHuggingPriority(.required, for: .vertical)
- cell.setContentCompressionResistancePriority(.required, for: .vertical)
- 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
- cell.setContentHuggingPriority(.required, for: .vertical)
- cell.setContentCompressionResistancePriority(.required, for: .vertical)
- 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 selectPlan(_ plan: PaywallPlan) {
- selectedPlan = plan
- for (key, card) in planCards {
- card.isChosen = key == plan
- }
- ctaButton.title = plan.localizedCTATitle(
- from: StoreManager.shared.product(for: plan),
- config: paywallConfig,
- trial: StoreManager.shared.trialDisplay(for: plan)
- )
- updateRenewalDisclosure(
- plan.localizedRenewalDisclosure(
- from: StoreManager.shared.product(for: plan),
- config: paywallConfig,
- trial: StoreManager.shared.trialDisplay(for: plan)
- )
- )
- refreshTrustItemsForSelectedPlan()
- refreshPurchaseState()
- refreshSecurePaymentNote()
- }
- private func retryProductLoad() {
- Task { @MainActor in
- refreshProductsErrorState()
- refreshPurchaseState()
- await StoreManager.shared.loadProducts(presentLoadingUI: true)
- refreshStoreState()
- }
- }
- @objc private func purchaseTapped() {
- Task { @MainActor in
- StoreManager.shared.clearPurchaseError()
- refreshPurchaseState()
- let purchased = await StoreManager.shared.purchase(plan: selectedPlan)
- refreshStoreState()
- if purchased {
- onClose?()
- } else if let error = StoreManager.shared.purchaseError {
- StoreManager.shared.showPurchaseError(error, on: window)
- }
- }
- }
- @objc private func restoreTapped() {
- Task { @MainActor in
- StoreManager.shared.clearPurchaseError()
- refreshPurchaseState()
- let restored = await StoreManager.shared.restorePurchases()
- refreshStoreState()
- if restored {
- onClose?()
- } else if let error = StoreManager.shared.purchaseError {
- StoreManager.shared.showPurchaseError(error, on: window)
- }
- }
- }
- @objc private func continueWithFreePlanTapped() {
- onClose?()
- }
- @objc private func premiumCloseTapped() {
- onClose?()
- }
- @objc private func manageSubscriptionTapped() {
- StoreManager.shared.showManageSubscriptions()
- }
- @objc private func privacyPolicyTapped() {
- openExternalLink(paywallConfig.urls.privacy)
- }
- @objc private func termsOfServiceTapped() {
- openExternalLink(paywallConfig.urls.terms)
- }
- @objc private func supportTapped() {
- openExternalLink(paywallConfig.urls.support)
- }
- private func openExternalLink(_ urlString: String) {
- guard let url = URL(string: urlString) else { return }
- NSWorkspace.shared.open(url)
- }
- }
- // MARK: - Overlay Presenter
- final class PaywallOverlayView: NSView, AppearanceRefreshable {
- var onDismiss: (() -> Void)?
- private let paywallView: PaywallView
- private let blurView = NSVisualEffectView()
- private let backdrop = NSView()
- private let pattern = WavePatternView()
- private let loadingOverlay = NSView()
- private let loadingIndicator = NSProgressIndicator()
- private var acceptsHitTesting = false
- private var storeObservers: [NSObjectProtocol] = []
- init() {
- paywallView = PaywallView()
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- setup()
- observeStoreUpdates()
- refreshAppearance()
- refreshLoadingOverlay()
- }
- deinit {
- storeObservers.forEach { NotificationCenter.default.removeObserver($0) }
- }
- private func observeStoreUpdates() {
- let center = NotificationCenter.default
- storeObservers = [
- center.addObserver(forName: .storeStateDidChange, object: nil, queue: .main) { [weak self] _ in
- self?.refreshLoadingOverlay()
- },
- center.addObserver(forName: .storeProductsDidUpdate, object: nil, queue: .main) { [weak self] _ in
- self?.refreshLoadingOverlay()
- },
- ]
- }
- private func refreshLoadingOverlay() {
- let store = StoreManager.shared
- let shouldShow = store.isLoadingProducts && !store.hasAvailablePlans && store.productLoadError == nil
- loadingOverlay.isHidden = !shouldShow
- if shouldShow {
- loadingIndicator.startAnimation(nil)
- } else {
- loadingIndicator.stopAnimation(nil)
- }
- }
- func refreshAppearance() {
- backdrop.layer?.backgroundColor = AppTheme.paywallOverlayBackdrop.cgColor
- blurView.material = AppSettings.darkModeEnabled ? .hudWindow : .underWindowBackground
- pattern.refreshAppearance()
- paywallView.refreshAppearance()
- }
- func refreshStoreState() {
- paywallView.refreshStoreState()
- refreshLoadingOverlay()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- override func hitTest(_ point: NSPoint) -> NSView? {
- guard acceptsHitTesting else { return nil }
- return super.hitTest(point)
- }
- private func setup() {
- blurView.translatesAutoresizingMaskIntoConstraints = false
- blurView.material = .underWindowBackground
- blurView.blendingMode = .withinWindow
- blurView.state = .active
- backdrop.translatesAutoresizingMaskIntoConstraints = false
- backdrop.wantsLayer = true
- backdrop.layer?.backgroundColor = NSColor(calibratedWhite: 0.15, alpha: 0.22).cgColor
- pattern.translatesAutoresizingMaskIntoConstraints = false
- pattern.alphaValue = 0.35
- paywallView.translatesAutoresizingMaskIntoConstraints = false
- paywallView.onClose = { [weak self] in self?.dismiss() }
- loadingOverlay.translatesAutoresizingMaskIntoConstraints = false
- loadingOverlay.wantsLayer = true
- loadingOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.08).cgColor
- loadingOverlay.isHidden = true
- loadingIndicator.style = .spinning
- loadingIndicator.controlSize = .large
- loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
- loadingOverlay.addSubview(loadingIndicator)
- addSubview(blurView)
- addSubview(backdrop)
- backdrop.addSubview(pattern)
- addSubview(paywallView)
- addSubview(loadingOverlay)
- NSLayoutConstraint.activate([
- blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
- blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
- blurView.topAnchor.constraint(equalTo: topAnchor),
- blurView.bottomAnchor.constraint(equalTo: bottomAnchor),
- backdrop.leadingAnchor.constraint(equalTo: leadingAnchor),
- backdrop.trailingAnchor.constraint(equalTo: trailingAnchor),
- backdrop.topAnchor.constraint(equalTo: topAnchor),
- backdrop.bottomAnchor.constraint(equalTo: bottomAnchor),
- pattern.leadingAnchor.constraint(equalTo: backdrop.leadingAnchor),
- pattern.trailingAnchor.constraint(equalTo: backdrop.trailingAnchor),
- pattern.topAnchor.constraint(equalTo: backdrop.topAnchor),
- pattern.bottomAnchor.constraint(equalTo: backdrop.bottomAnchor),
- paywallView.leadingAnchor.constraint(equalTo: leadingAnchor),
- paywallView.trailingAnchor.constraint(equalTo: trailingAnchor),
- paywallView.topAnchor.constraint(equalTo: topAnchor),
- paywallView.bottomAnchor.constraint(equalTo: bottomAnchor),
- loadingOverlay.leadingAnchor.constraint(equalTo: leadingAnchor),
- loadingOverlay.trailingAnchor.constraint(equalTo: trailingAnchor),
- loadingOverlay.topAnchor.constraint(equalTo: topAnchor),
- loadingOverlay.bottomAnchor.constraint(equalTo: bottomAnchor),
- loadingIndicator.centerXAnchor.constraint(equalTo: loadingOverlay.centerXAnchor),
- loadingIndicator.centerYAnchor.constraint(equalTo: loadingOverlay.centerYAnchor),
- ])
- }
- func present(in parent: NSView) {
- guard superview == nil else { return }
- if let window = parent.window,
- let windowFrameView = window.contentView?.superview {
- windowFrameView.addSubview(self)
- NSLayoutConstraint.activate([
- leadingAnchor.constraint(equalTo: windowFrameView.leadingAnchor),
- trailingAnchor.constraint(equalTo: windowFrameView.trailingAnchor),
- topAnchor.constraint(equalTo: windowFrameView.topAnchor),
- bottomAnchor.constraint(equalTo: windowFrameView.bottomAnchor),
- ])
- } else {
- parent.addSubview(self)
- NSLayoutConstraint.activate([
- leadingAnchor.constraint(equalTo: parent.leadingAnchor),
- trailingAnchor.constraint(equalTo: parent.trailingAnchor),
- topAnchor.constraint(equalTo: parent.topAnchor),
- bottomAnchor.constraint(equalTo: parent.bottomAnchor),
- ])
- }
- alphaValue = 1
- acceptsHitTesting = true
- paywallView.refreshStoreState()
- Task { @MainActor in
- await StoreManager.shared.ensurePaywallStoreDataLoaded()
- paywallView.refreshStoreState()
- refreshLoadingOverlay()
- }
- Task { @MainActor in
- await PaywallConfigService.shared.refreshFromRemote()
- paywallView.refreshStoreState()
- }
- }
- func dismiss() {
- acceptsHitTesting = false
- NSAnimationContext.runAnimationGroup({ context in
- context.duration = 0.15
- animator().alphaValue = 0
- }, completionHandler: { [weak self] in
- self?.removeFromSuperview()
- self?.onDismiss?()
- })
- }
- }
|