PaywallView.swift 88 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427
  1. import Cocoa
  2. import StoreKit
  3. // MARK: - StoreKit
  4. enum StoreProductID {
  5. static let monthly = AppStoreConfig.ProductID.monthly
  6. static let yearly = AppStoreConfig.ProductID.yearly
  7. static let lifetime = AppStoreConfig.ProductID.lifetime
  8. static let all = AppStoreConfig.ProductID.all
  9. static func plan(for productID: String) -> PaywallPlan? {
  10. switch productID {
  11. case monthly: .monthly
  12. case yearly: .yearly
  13. case lifetime: .lifetime
  14. default: nil
  15. }
  16. }
  17. }
  18. enum PremiumAccessKind: String {
  19. case none
  20. case subscription
  21. case lifetime
  22. }
  23. enum StoreError: Error {
  24. case productNotFound
  25. case failedVerification
  26. }
  27. enum PurchaseOutcome: Sendable {
  28. case purchased
  29. case cancelled
  30. case pending
  31. }
  32. @MainActor
  33. final class StoreManager {
  34. static let shared = StoreManager()
  35. private enum ProductLoadError: Error {
  36. case timeout
  37. }
  38. private enum StoreSyncError: Error {
  39. case timeout
  40. }
  41. private static let cachedPremiumKey = "StoreManager.cachedIsPremium"
  42. private static let cachedPremiumPlanKey = "StoreManager.cachedPremiumPlan"
  43. private static let cachedHasEverPurchasedKey = "StoreManager.cachedHasEverPurchased"
  44. private static let backgroundRefreshInterval: TimeInterval = 120
  45. private(set) var productsByID: [String: Product] = [:]
  46. private(set) var isPremium = UserDefaults.standard.bool(forKey: cachedPremiumKey)
  47. private(set) var activePremiumPlan: PaywallPlan?
  48. private(set) var hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: cachedHasEverPurchasedKey)
  49. private(set) var trialDisplayByPlan: [PaywallPlan: PaywallTrialDisplay] = [:]
  50. private(set) var purchaseError: PurchaseError?
  51. var isPro: Bool { isPremium }
  52. var hasActiveSubscription: Bool { activePremiumPlan == .monthly || activePremiumPlan == .yearly }
  53. var hasLifetimeAccess: Bool { activePremiumPlan == .lifetime }
  54. var premiumAccessKind: PremiumAccessKind {
  55. switch activePremiumPlan {
  56. case .lifetime: .lifetime
  57. case .monthly, .yearly: .subscription
  58. case nil: .none
  59. }
  60. }
  61. var products: [Product] {
  62. availablePlans.compactMap { productsByID[$0.productID] }
  63. }
  64. var availablePlans: [PaywallPlan] {
  65. PaywallPlan.displayOrder.filter { productsByID[$0.productID] != nil }
  66. }
  67. var isResolvingEntitlements: Bool { !hasResolvedPremiumStatus }
  68. var hasResolvedPremiumStatus = false
  69. var hasAvailablePlans: Bool { !availablePlans.isEmpty }
  70. var hasAllProductsLoaded: Bool { hasAvailablePlans }
  71. private(set) var isLoadingProducts = false
  72. private(set) var productLoadError: String?
  73. private(set) var purchasingPlan: PaywallPlan?
  74. private(set) var isRestoringPurchases = false
  75. var isPurchasing: Bool { purchasingPlan != nil || isRestoringPurchases }
  76. private var inFlightProductLoad: Task<Void, Never>?
  77. private var transactionListener: Task<Void, Never>?
  78. private var initialEntitlementTask: Task<Void, Never>?
  79. private var entitlementRefreshTask: Task<Void, Never>?
  80. private var hasStarted = false
  81. private var lastBackgroundEntitlementRefresh: Date?
  82. private var appActiveObserver: NSObjectProtocol?
  83. private init() {
  84. migrateLegacyPremiumCacheIfNeeded()
  85. hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: Self.cachedHasEverPurchasedKey)
  86. if UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) {
  87. isPremium = true
  88. activePremiumPlan = restoredCachedPremiumPlan()
  89. }
  90. }
  91. private func migrateLegacyPremiumCacheIfNeeded() {
  92. guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return }
  93. guard UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey) == nil,
  94. let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") else {
  95. return
  96. }
  97. let migratedPlan: String?
  98. switch legacyKind {
  99. case PremiumAccessKind.lifetime.rawValue:
  100. migratedPlan = PaywallPlan.lifetime.rawValue
  101. case PremiumAccessKind.subscription.rawValue:
  102. migratedPlan = PaywallPlan.yearly.rawValue
  103. default:
  104. migratedPlan = nil
  105. }
  106. if let migratedPlan {
  107. UserDefaults.standard.set(migratedPlan, forKey: Self.cachedPremiumPlanKey)
  108. }
  109. }
  110. private func restoredCachedPremiumPlan() -> PaywallPlan? {
  111. guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return nil }
  112. if let raw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
  113. let plan = PaywallPlan(rawValue: raw) {
  114. return plan
  115. }
  116. if let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") {
  117. switch legacyKind {
  118. case PremiumAccessKind.lifetime.rawValue:
  119. return .lifetime
  120. case PremiumAccessKind.subscription.rawValue:
  121. return .yearly
  122. default:
  123. break
  124. }
  125. }
  126. return .yearly
  127. }
  128. func start() {
  129. guard !hasStarted else { return }
  130. hasStarted = true
  131. observeAppLifecycle()
  132. transactionListener = Task { [weak self] in
  133. await self?.listenForTransactionUpdates()
  134. }
  135. initialEntitlementTask = Task { [weak self] in
  136. guard let self else { return }
  137. await self.refreshPremiumAccess()
  138. await self.loadProducts(presentLoadingUI: false)
  139. }
  140. }
  141. private func observeAppLifecycle() {
  142. appActiveObserver = NotificationCenter.default.addObserver(
  143. forName: NSApplication.didBecomeActiveNotification,
  144. object: nil,
  145. queue: .main
  146. ) { [weak self] _ in
  147. Task { @MainActor in
  148. self?.refreshEntitlementsOnAppActive()
  149. }
  150. }
  151. }
  152. private func refreshEntitlementsOnAppActive() {
  153. guard hasStarted else { return }
  154. if let lastRefresh = lastBackgroundEntitlementRefresh,
  155. Date().timeIntervalSince(lastRefresh) < Self.backgroundRefreshInterval {
  156. return
  157. }
  158. entitlementRefreshTask?.cancel()
  159. entitlementRefreshTask = Task {
  160. await refreshPremiumAccess()
  161. lastBackgroundEntitlementRefresh = Date()
  162. }
  163. }
  164. func ensureEntitlementsResolved() async {
  165. await initialEntitlementTask?.value
  166. }
  167. func ensurePaywallStoreDataLoaded(presentLoadingUI: Bool = true) async {
  168. await ensureEntitlementsResolved()
  169. await loadProducts(presentLoadingUI: presentLoadingUI && !hasAllProductsLoaded)
  170. await refreshTrialOffers()
  171. }
  172. func product(for plan: PaywallPlan) -> Product? {
  173. productsByID[plan.productID]
  174. }
  175. func trialDisplay(for plan: PaywallPlan) -> PaywallTrialDisplay? {
  176. trialDisplayByPlan[plan]
  177. }
  178. func clearPurchaseError() {
  179. purchaseError = nil
  180. }
  181. var isEligibleForIntroTrial: Bool {
  182. !hasEverPurchasedPremium && !isPremium
  183. }
  184. func loadProducts(presentLoadingUI: Bool? = nil) async {
  185. if let existing = inFlightProductLoad {
  186. await existing.value
  187. return
  188. }
  189. let uiFlag = presentLoadingUI
  190. let task = Task { @MainActor in
  191. await performLoadProducts(presentLoadingUI: uiFlag)
  192. }
  193. inFlightProductLoad = task
  194. await task.value
  195. inFlightProductLoad = nil
  196. }
  197. @discardableResult
  198. func purchase(plan: PaywallPlan) async -> Bool {
  199. purchaseError = nil
  200. var product = productsByID[plan.productID]
  201. if product == nil {
  202. await loadProducts(presentLoadingUI: false)
  203. product = productsByID[plan.productID]
  204. }
  205. guard let product else {
  206. purchaseError = products.isEmpty ? .subscriptionUnavailable : .planUnavailable
  207. return false
  208. }
  209. purchasingPlan = plan
  210. postStoreStateDidChange()
  211. defer {
  212. purchasingPlan = nil
  213. postStoreStateDidChange()
  214. }
  215. do {
  216. let result = try await product.purchase()
  217. switch result {
  218. case .success(let verification):
  219. guard case .verified(let transaction) = verification else {
  220. purchaseError = .purchaseNotVerified
  221. return false
  222. }
  223. let trustedPlan = planGrantingPremium(from: transaction)
  224. if let trustedPlan {
  225. applyPremiumAccess(from: transaction)
  226. }
  227. await transaction.finish()
  228. await reconcilePremiumAccess(trustedPlan: trustedPlan)
  229. return isPremium
  230. case .userCancelled:
  231. return false
  232. case .pending:
  233. purchaseError = .purchasePending
  234. return false
  235. @unknown default:
  236. return false
  237. }
  238. } catch {
  239. purchaseError = .generic
  240. return false
  241. }
  242. }
  243. @discardableResult
  244. func restorePurchases() async -> Bool {
  245. purchaseError = nil
  246. isRestoringPurchases = true
  247. postStoreStateDidChange()
  248. defer {
  249. isRestoringPurchases = false
  250. postStoreStateDidChange()
  251. }
  252. do {
  253. try await syncAppStoreWithTimeout(seconds: 30)
  254. } catch is StoreSyncError {
  255. purchaseError = .restoreSyncTimeout
  256. return false
  257. } catch {
  258. purchaseError = .generic
  259. return false
  260. }
  261. await reconcilePremiumAccess(trustedPlan: nil)
  262. await syncPurchaseHistory(allowReset: true)
  263. if !isPremium {
  264. purchaseError = .noActivePurchases
  265. }
  266. return isPremium
  267. }
  268. func showAlert(title: String, message: String, on window: NSWindow?) {
  269. let alert = NSAlert()
  270. alert.messageText = title
  271. alert.informativeText = message
  272. alert.alertStyle = .informational
  273. alert.addButton(withTitle: PaywallConfigService.shared.config.messages.alertOK)
  274. if let window {
  275. alert.beginSheetModal(for: window)
  276. } else {
  277. alert.runModal()
  278. }
  279. }
  280. func showManageSubscriptions() {
  281. let urlString = PaywallConfigService.shared.config.urls.manageSubscriptions
  282. guard let url = URL(string: urlString) else { return }
  283. NSWorkspace.shared.open(url)
  284. }
  285. func showPurchaseError(_ error: PurchaseError, on window: NSWindow?) {
  286. let config = PaywallConfigService.shared.config
  287. showAlert(
  288. title: config.messages.purchaseFailedTitle,
  289. message: config.message(for: error),
  290. on: window
  291. )
  292. }
  293. private func performLoadProducts(presentLoadingUI: Bool?) async {
  294. let shouldReload = presentLoadingUI == true || productsByID.isEmpty
  295. let shouldShowLoading = presentLoadingUI ?? productsByID.isEmpty
  296. if !shouldReload, presentLoadingUI != true {
  297. purchaseError = nil
  298. await refreshPremiumAccess()
  299. await refreshTrialOffers()
  300. postStoreStateDidChange()
  301. return
  302. }
  303. if shouldShowLoading {
  304. isLoadingProducts = true
  305. postStoreStateDidChange()
  306. }
  307. defer {
  308. if shouldShowLoading {
  309. isLoadingProducts = false
  310. postStoreStateDidChange()
  311. }
  312. }
  313. purchaseError = nil
  314. productLoadError = nil
  315. do {
  316. let loaded = try await loadProductsWithTimeout(seconds: 12)
  317. guard !loaded.isEmpty else {
  318. productLoadError = PaywallConfigService.shared.config.messages.noPlansAvailable
  319. productsByID = [:]
  320. await refreshTrialOffers()
  321. return
  322. }
  323. var map: [String: Product] = [:]
  324. for product in loaded {
  325. map[product.id] = product
  326. }
  327. productsByID = map
  328. productLoadError = nil
  329. NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
  330. await refreshPremiumAccess()
  331. await refreshTrialOffers()
  332. } catch is ProductLoadError {
  333. productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
  334. purchaseError = .productsLoadTimeout
  335. await refreshTrialOffers()
  336. } catch {
  337. productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
  338. purchaseError = .generic
  339. await refreshTrialOffers()
  340. }
  341. }
  342. private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] {
  343. try await withThrowingTaskGroup(of: [Product].self) { group in
  344. group.addTask {
  345. try await Product.products(for: StoreProductID.all)
  346. }
  347. group.addTask {
  348. let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
  349. try await Task.sleep(nanoseconds: timeoutNanoseconds)
  350. throw ProductLoadError.timeout
  351. }
  352. guard let first = try await group.next() else {
  353. throw ProductLoadError.timeout
  354. }
  355. group.cancelAll()
  356. return first
  357. }
  358. }
  359. private func refreshPremiumAccess(trustedPlan: PaywallPlan? = nil) async {
  360. var resolvedPlan: PaywallPlan?
  361. if let lifetimePlan = await activeLifetimePlan() {
  362. resolvedPlan = lifetimePlan
  363. }
  364. if resolvedPlan == nil {
  365. for await result in Transaction.currentEntitlements {
  366. guard case .verified(let transaction) = result else { continue }
  367. guard let plan = planGrantingPremium(from: transaction) else { continue }
  368. resolvedPlan = preferredPlan(resolvedPlan, plan)
  369. }
  370. }
  371. if resolvedPlan == nil || resolvedPlan != .lifetime {
  372. if let subscriptionPlan = await activeSubscriptionPlan() {
  373. resolvedPlan = preferredPlan(resolvedPlan, subscriptionPlan)
  374. }
  375. }
  376. if resolvedPlan == nil {
  377. if let statusPlan = await activePlanFromSubscriptionStatus() {
  378. resolvedPlan = statusPlan
  379. }
  380. }
  381. let finalPlan = mergeTrustedPlan(resolvedPlan, trustedPlan)
  382. applyPremiumStatus(plan: finalPlan)
  383. hasResolvedPremiumStatus = true
  384. NotificationCenter.default.post(name: .entitlementsDidResolve, object: nil)
  385. await syncPurchaseHistory(allowReset: false)
  386. await refreshTrialOffers()
  387. }
  388. private func reconcilePremiumAccess(trustedPlan: PaywallPlan?) async {
  389. await refreshPremiumAccess(trustedPlan: trustedPlan)
  390. guard let trustedPlan, !isPremium else { return }
  391. do {
  392. try await syncAppStoreWithTimeout(seconds: 15)
  393. } catch {
  394. applyTrustedPlan(trustedPlan)
  395. return
  396. }
  397. await refreshPremiumAccess(trustedPlan: trustedPlan)
  398. if !isPremium {
  399. applyTrustedPlan(trustedPlan)
  400. }
  401. }
  402. private func mergeTrustedPlan(_ resolvedPlan: PaywallPlan?, _ trustedPlan: PaywallPlan?) -> PaywallPlan? {
  403. switch (resolvedPlan, trustedPlan) {
  404. case (nil, nil):
  405. nil
  406. case (let resolved?, nil):
  407. resolved
  408. case (nil, let trusted?):
  409. trusted
  410. case (let resolved?, let trusted?):
  411. preferredPlan(resolved, trusted)
  412. }
  413. }
  414. private func applyTrustedPlan(_ plan: PaywallPlan) {
  415. activePremiumPlan = preferredPlan(activePremiumPlan, plan)
  416. applyPremiumStatus(plan: activePremiumPlan)
  417. hasResolvedPremiumStatus = true
  418. markAsHavingPurchasedPremium()
  419. }
  420. private func revokePremiumAccess() {
  421. applyPremiumStatus(plan: nil)
  422. hasResolvedPremiumStatus = true
  423. trialDisplayByPlan = [:]
  424. }
  425. private func syncAppStoreWithTimeout(seconds: TimeInterval) async throws {
  426. try await withThrowingTaskGroup(of: Void.self) { group in
  427. group.addTask {
  428. try await AppStore.sync()
  429. }
  430. group.addTask {
  431. let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
  432. try await Task.sleep(nanoseconds: timeoutNanoseconds)
  433. throw StoreSyncError.timeout
  434. }
  435. try await group.next()
  436. group.cancelAll()
  437. }
  438. }
  439. private func applyPremiumAccess(from transaction: StoreKit.Transaction) {
  440. guard let plan = planGrantingPremium(from: transaction) else { return }
  441. activePremiumPlan = preferredPlan(activePremiumPlan, plan)
  442. applyPremiumStatus(plan: activePremiumPlan)
  443. hasResolvedPremiumStatus = true
  444. markAsHavingPurchasedPremium()
  445. }
  446. private func markAsHavingPurchasedPremium() {
  447. guard !hasEverPurchasedPremium else { return }
  448. hasEverPurchasedPremium = true
  449. trialDisplayByPlan = [:]
  450. UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
  451. }
  452. private func syncPurchaseHistory(allowReset: Bool) async {
  453. var hasRecordedPurchase = false
  454. for await result in Transaction.all {
  455. guard case .verified(let transaction) = result else { continue }
  456. guard StoreProductID.all.contains(transaction.productID) else { continue }
  457. hasRecordedPurchase = true
  458. break
  459. }
  460. if hasRecordedPurchase {
  461. if !hasEverPurchasedPremium {
  462. hasEverPurchasedPremium = true
  463. UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
  464. }
  465. } else if allowReset, hasEverPurchasedPremium {
  466. resetToNeverPurchasedState()
  467. }
  468. }
  469. private func resetToNeverPurchasedState() {
  470. hasEverPurchasedPremium = false
  471. UserDefaults.standard.set(false, forKey: Self.cachedHasEverPurchasedKey)
  472. trialDisplayByPlan = [:]
  473. }
  474. private func refreshTrialOffers() async {
  475. guard !isPremium else {
  476. trialDisplayByPlan = [:]
  477. postStoreStateDidChange()
  478. return
  479. }
  480. guard let eligiblePlan = PaywallConfigService.shared.config.trialEligiblePlan else {
  481. trialDisplayByPlan = [:]
  482. postStoreStateDidChange()
  483. return
  484. }
  485. if let display = await resolveTrialDisplay(for: eligiblePlan) {
  486. trialDisplayByPlan = [eligiblePlan: display]
  487. } else {
  488. trialDisplayByPlan = [:]
  489. }
  490. postStoreStateDidChange()
  491. }
  492. private func resolveTrialDisplay(for plan: PaywallPlan) async -> PaywallTrialDisplay? {
  493. guard plan == PaywallConfigService.shared.config.trialEligiblePlan else { return nil }
  494. guard !isPremium else { return nil }
  495. let config = PaywallConfigService.shared.config
  496. guard let product = product(for: plan),
  497. let subscription = product.subscription else {
  498. return fallbackTrialDisplay(config: config)
  499. }
  500. guard await subscription.isEligibleForIntroOffer else { return nil }
  501. if let offer = subscription.introductoryOffer {
  502. return PaywallTrialDisplay.from(offer: offer, config: config)
  503. }
  504. return fallbackTrialDisplay(config: config)
  505. }
  506. private func fallbackTrialDisplay(config: PaywallConfig) -> PaywallTrialDisplay? {
  507. guard !isPremium else { return nil }
  508. guard let fallback = config.trial.fallbackDuration else { return nil }
  509. return PaywallTrialDisplay.from(fallback: fallback, config: config)
  510. }
  511. private func applyPremiumStatus(plan: PaywallPlan?) {
  512. let hasPremium = plan != nil
  513. let kind: PremiumAccessKind
  514. switch plan {
  515. case .lifetime: kind = .lifetime
  516. case .monthly, .yearly: kind = .subscription
  517. case nil: kind = .none
  518. }
  519. let previousKind = premiumAccessKind
  520. let previousPlan = activePremiumPlan
  521. let didChange = hasPremium != isPremium || kind != previousKind || plan != previousPlan
  522. isPremium = hasPremium
  523. activePremiumPlan = plan
  524. UserDefaults.standard.set(hasPremium, forKey: Self.cachedPremiumKey)
  525. if let plan {
  526. UserDefaults.standard.set(plan.rawValue, forKey: Self.cachedPremiumPlanKey)
  527. } else {
  528. UserDefaults.standard.removeObject(forKey: Self.cachedPremiumPlanKey)
  529. }
  530. if didChange {
  531. NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
  532. }
  533. postStoreStateDidChange()
  534. }
  535. private func planGrantingPremium(from transaction: StoreKit.Transaction) -> PaywallPlan? {
  536. guard transactionGrantsPremium(transaction) else { return nil }
  537. return StoreProductID.plan(for: transaction.productID)
  538. }
  539. private func transactionGrantsPremium(_ transaction: StoreKit.Transaction) -> Bool {
  540. guard StoreProductID.all.contains(transaction.productID) else { return false }
  541. guard transaction.revocationDate == nil else { return false }
  542. if let expirationDate = transaction.expirationDate, expirationDate < Date() {
  543. return false
  544. }
  545. return true
  546. }
  547. private func preferredPlan(_ current: PaywallPlan?, _ candidate: PaywallPlan) -> PaywallPlan {
  548. if current == .lifetime || candidate == .lifetime {
  549. return .lifetime
  550. }
  551. if current == .yearly || candidate == .yearly {
  552. return .yearly
  553. }
  554. if current == .monthly || candidate == .monthly {
  555. return .monthly
  556. }
  557. return candidate
  558. }
  559. private func activeLifetimePlan() async -> PaywallPlan? {
  560. guard let result = await Transaction.latest(for: StoreProductID.lifetime) else {
  561. return nil
  562. }
  563. guard case .verified(let transaction) = result else { return nil }
  564. return planGrantingPremium(from: transaction)
  565. }
  566. private func activeSubscriptionPlan() async -> PaywallPlan? {
  567. var resolvedPlan: PaywallPlan?
  568. for await result in Transaction.currentEntitlements {
  569. guard case .verified(let transaction) = result else { continue }
  570. guard let plan = planGrantingPremium(from: transaction), plan != .lifetime else { continue }
  571. resolvedPlan = preferredPlan(resolvedPlan, plan)
  572. }
  573. return resolvedPlan
  574. }
  575. private func activePlanFromSubscriptionStatus() async -> PaywallPlan? {
  576. let productIDs = [StoreProductID.monthly, StoreProductID.yearly]
  577. for productID in productIDs {
  578. let product: Product?
  579. if let cached = productsByID[productID] {
  580. product = cached
  581. } else {
  582. product = try? await Product.products(for: [productID]).first
  583. }
  584. guard let product,
  585. let subscription = product.subscription,
  586. let statuses = try? await subscription.status else {
  587. continue
  588. }
  589. for status in statuses {
  590. switch status.state {
  591. case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
  592. return StoreProductID.plan(for: productID)
  593. default:
  594. continue
  595. }
  596. }
  597. }
  598. return nil
  599. }
  600. private func listenForTransactionUpdates() async {
  601. for await update in Transaction.updates {
  602. guard case .verified(let transaction) = update else { continue }
  603. if isRevokedAppTransaction(transaction) {
  604. revokePremiumAccess()
  605. await transaction.finish()
  606. await refreshPremiumAccess()
  607. continue
  608. }
  609. let trustedPlan = planGrantingPremium(from: transaction)
  610. if let trustedPlan {
  611. applyPremiumAccess(from: transaction)
  612. }
  613. await transaction.finish()
  614. if trustedPlan != nil {
  615. await reconcilePremiumAccess(trustedPlan: trustedPlan)
  616. } else {
  617. await refreshPremiumAccess()
  618. }
  619. }
  620. }
  621. private func isRevokedAppTransaction(_ transaction: StoreKit.Transaction) -> Bool {
  622. StoreProductID.all.contains(transaction.productID) && transaction.revocationDate != nil
  623. }
  624. private func postStoreStateDidChange() {
  625. NotificationCenter.default.post(name: .storeStateDidChange, object: nil)
  626. }
  627. }
  628. extension Notification.Name {
  629. static let premiumStatusDidChange = Notification.Name("premiumStatusDidChange")
  630. static let entitlementsDidResolve = Notification.Name("entitlementsDidResolve")
  631. static let storeProductsDidUpdate = Notification.Name("storeProductsDidUpdate")
  632. static let storeStateDidChange = Notification.Name("storeStateDidChange")
  633. }
  634. // MARK: - Premium Access
  635. enum PremiumAccess {
  636. @MainActor
  637. static func canAccess(_ feature: AppFeature) -> Bool {
  638. let store = StoreManager.shared
  639. return FreeTierManager.canAccess(feature, isPremium: store.isPremium)
  640. }
  641. @MainActor
  642. @discardableResult
  643. static func require(
  644. feature: AppFeature,
  645. from window: NSWindow?,
  646. returnToHomeOnDismiss: Bool = false
  647. ) -> Bool {
  648. guard canAccess(feature) else {
  649. presentPaywallOrLimitAlert(for: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
  650. return false
  651. }
  652. return true
  653. }
  654. @MainActor
  655. @discardableResult
  656. static func requireForAction(
  657. feature: AppFeature,
  658. from window: NSWindow?,
  659. returnToHomeOnDismiss: Bool = false,
  660. recordFreeUse: Bool = true
  661. ) -> Bool {
  662. guard require(feature: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss) else {
  663. return false
  664. }
  665. if recordFreeUse {
  666. recordFreeUseIfNeeded()
  667. }
  668. return true
  669. }
  670. @MainActor
  671. static func recordFreeUseIfNeeded() {
  672. guard !StoreManager.shared.isPremium else { return }
  673. FreeTierManager.recordUseIfNeeded(isPremium: false)
  674. }
  675. @MainActor
  676. @discardableResult
  677. static func require(from window: NSWindow?, returnToHomeOnDismiss: Bool = false) -> Bool {
  678. require(feature: .documentImport, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
  679. }
  680. @MainActor
  681. private static func presentPaywallOrLimitAlert(
  682. for feature: AppFeature,
  683. from window: NSWindow?,
  684. returnToHomeOnDismiss: Bool
  685. ) {
  686. if StoreManager.shared.isResolvingEntitlements {
  687. Task { @MainActor in
  688. await StoreManager.shared.ensureEntitlementsResolved()
  689. guard !canAccess(feature) else { return }
  690. presentPaywallOrLimitAlert(for: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
  691. }
  692. return
  693. }
  694. guard let viewController = mainViewController(from: window) else { return }
  695. viewController.presentPaywall(returnToHomeOnDismiss: returnToHomeOnDismiss)
  696. }
  697. @MainActor
  698. static func mainViewController(from window: NSWindow?) -> ViewController? {
  699. if let viewController = window?.contentViewController as? ViewController {
  700. return viewController
  701. }
  702. if let viewController = NSApp.keyWindow?.contentViewController as? ViewController {
  703. return viewController
  704. }
  705. for appWindow in NSApp.windows where appWindow.canBecomeMain {
  706. if let viewController = appWindow.contentViewController as? ViewController {
  707. return viewController
  708. }
  709. }
  710. return nil
  711. }
  712. }
  713. // MARK: - Left Panel
  714. private final class PaywallLeftPanelView: NSView, AppearanceRefreshable {
  715. private let gradientLayer = CAGradientLayer()
  716. override init(frame frameRect: NSRect) {
  717. super.init(frame: frameRect)
  718. wantsLayer = true
  719. gradientLayer.startPoint = CGPoint(x: 0.5, y: 1)
  720. gradientLayer.endPoint = CGPoint(x: 0.5, y: 0)
  721. layer?.insertSublayer(gradientLayer, at: 0)
  722. refreshAppearance()
  723. }
  724. func refreshAppearance() {
  725. gradientLayer.colors = AppTheme.paywallLeftGradientColors.map(\.cgColor)
  726. }
  727. @available(*, unavailable)
  728. required init?(coder: NSCoder) { nil }
  729. override func layout() {
  730. super.layout()
  731. gradientLayer.frame = bounds
  732. let mask = CAShapeLayer()
  733. mask.path = CGPath(
  734. roundedRect: bounds,
  735. cornerWidth: 20,
  736. cornerHeight: 20,
  737. transform: nil
  738. )
  739. layer?.mask = mask
  740. }
  741. }
  742. // MARK: - Badge
  743. private final class PaywallBadgeView: NSView {
  744. private let label: NSTextField
  745. init(text: String, iconName: String, background: NSColor, foreground: NSColor) {
  746. label = NSTextField(labelWithString: text)
  747. super.init(frame: .zero)
  748. translatesAutoresizingMaskIntoConstraints = false
  749. wantsLayer = true
  750. layer?.backgroundColor = background.cgColor
  751. layer?.cornerRadius = 10
  752. layer?.masksToBounds = true
  753. let icon = NSImageView()
  754. icon.translatesAutoresizingMaskIntoConstraints = false
  755. if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
  756. let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .semibold)
  757. icon.image = image.withSymbolConfiguration(config)
  758. }
  759. icon.contentTintColor = foreground
  760. label.font = AppTheme.semiboldFont(size: 10)
  761. label.textColor = foreground
  762. label.translatesAutoresizingMaskIntoConstraints = false
  763. addSubview(icon)
  764. addSubview(label)
  765. NSLayoutConstraint.activate([
  766. heightAnchor.constraint(equalToConstant: 20),
  767. icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
  768. icon.centerYAnchor.constraint(equalTo: centerYAnchor),
  769. icon.widthAnchor.constraint(equalToConstant: 12),
  770. icon.heightAnchor.constraint(equalToConstant: 12),
  771. label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 4),
  772. label.centerYAnchor.constraint(equalTo: centerYAnchor),
  773. label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
  774. ])
  775. }
  776. func updateText(_ text: String) {
  777. label.stringValue = text
  778. }
  779. @available(*, unavailable)
  780. required init?(coder: NSCoder) { nil }
  781. }
  782. // MARK: - Feature Row
  783. private final class PaywallFeatureRow: NSView, AppearanceRefreshable {
  784. private let label: NSTextField
  785. init(text: String) {
  786. label = NSTextField.themeLabel(text, style: .primary, font: AppTheme.regularFont(size: 14))
  787. super.init(frame: .zero)
  788. translatesAutoresizingMaskIntoConstraints = false
  789. let checkContainer = NSView()
  790. checkContainer.translatesAutoresizingMaskIntoConstraints = false
  791. checkContainer.wantsLayer = true
  792. checkContainer.layer?.backgroundColor = AppTheme.green.cgColor
  793. checkContainer.layer?.cornerRadius = 10
  794. checkContainer.layer?.masksToBounds = true
  795. let checkIcon = NSImageView()
  796. checkIcon.translatesAutoresizingMaskIntoConstraints = false
  797. if let image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil) {
  798. let config = NSImage.SymbolConfiguration(pointSize: 9, weight: .bold)
  799. checkIcon.image = image.withSymbolConfiguration(config)
  800. }
  801. checkIcon.contentTintColor = .white
  802. label.translatesAutoresizingMaskIntoConstraints = false
  803. addSubview(checkContainer)
  804. checkContainer.addSubview(checkIcon)
  805. addSubview(label)
  806. NSLayoutConstraint.activate([
  807. heightAnchor.constraint(equalToConstant: 28),
  808. checkContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
  809. checkContainer.centerYAnchor.constraint(equalTo: centerYAnchor),
  810. checkContainer.widthAnchor.constraint(equalToConstant: 20),
  811. checkContainer.heightAnchor.constraint(equalToConstant: 20),
  812. checkIcon.centerXAnchor.constraint(equalTo: checkContainer.centerXAnchor),
  813. checkIcon.centerYAnchor.constraint(equalTo: checkContainer.centerYAnchor),
  814. checkIcon.widthAnchor.constraint(equalToConstant: 12),
  815. checkIcon.heightAnchor.constraint(equalToConstant: 12),
  816. label.leadingAnchor.constraint(equalTo: checkContainer.trailingAnchor, constant: 12),
  817. label.centerYAnchor.constraint(equalTo: centerYAnchor),
  818. label.trailingAnchor.constraint(equalTo: trailingAnchor),
  819. ])
  820. }
  821. func updateText(_ text: String) {
  822. label.stringValue = text
  823. }
  824. @available(*, unavailable)
  825. required init?(coder: NSCoder) { nil }
  826. func refreshAppearance() {
  827. label.refreshThemeLabelColor()
  828. }
  829. }
  830. // MARK: - Plan Card
  831. private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
  832. var onSelect: (() -> Void)?
  833. private let plan: PaywallPlan
  834. private let titleLabel = NSTextField(labelWithString: "")
  835. private let subtitleLabel = NSTextField(labelWithString: "")
  836. private let priceLabel = NSTextField(labelWithString: "")
  837. private var trialBadgeView: PaywallBadgeView?
  838. private var hoverTracker: HoverTracker?
  839. private var isHovered = false
  840. var isChosen: Bool = false {
  841. didSet { updateAppearance() }
  842. }
  843. init(plan: PaywallPlan) {
  844. self.plan = plan
  845. super.init(frame: .zero)
  846. translatesAutoresizingMaskIntoConstraints = false
  847. wantsLayer = true
  848. layer?.cornerRadius = 12
  849. layer?.masksToBounds = false
  850. let config = PaywallConfigService.shared.config
  851. titleLabel.stringValue = plan.planCopy(from: config).title
  852. titleLabel.font = AppTheme.semiboldFont(size: 15)
  853. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  854. subtitleLabel.stringValue = config.loadingPrice
  855. subtitleLabel.font = AppTheme.regularFont(size: 11)
  856. subtitleLabel.themeLabelStyle = .secondary
  857. subtitleLabel.textColor = AppTheme.textSecondary
  858. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  859. priceLabel.stringValue = config.loadingPrice
  860. priceLabel.font = AppTheme.semiboldFont(size: 15)
  861. priceLabel.alignment = .right
  862. priceLabel.translatesAutoresizingMaskIntoConstraints = false
  863. addSubview(titleLabel)
  864. addSubview(subtitleLabel)
  865. addSubview(priceLabel)
  866. NSLayoutConstraint.activate([
  867. heightAnchor.constraint(equalToConstant: 86),
  868. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
  869. titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 24),
  870. subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  871. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 3),
  872. subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: priceLabel.leadingAnchor, constant: -12),
  873. priceLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
  874. priceLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  875. ])
  876. applyCardShadow()
  877. updateAppearance()
  878. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  879. self?.setHovered(hovering)
  880. }
  881. }
  882. @available(*, unavailable)
  883. required init?(coder: NSCoder) { nil }
  884. func updateDisplay(
  885. product: Product?,
  886. config: PaywallConfig,
  887. trial: PaywallTrialDisplay? = nil
  888. ) {
  889. titleLabel.stringValue = plan.planCopy(from: config).title
  890. subtitleLabel.stringValue = plan.localizedSubtitle(from: product, config: config, trial: trial)
  891. priceLabel.stringValue = plan.localizedPrice(from: product, config: config)
  892. updateTrialBadge(trial: trial)
  893. }
  894. private func updateTrialBadge(trial: PaywallTrialDisplay?) {
  895. guard plan == PaywallConfigService.shared.config.trialEligiblePlan else { return }
  896. if let trial {
  897. let text = trial.badgeText
  898. if let trialBadgeView {
  899. trialBadgeView.updateText(text)
  900. trialBadgeView.isHidden = false
  901. } else {
  902. let badge = PaywallBadgeView(
  903. text: text,
  904. iconName: "calendar",
  905. background: AppTheme.paywallPink,
  906. foreground: AppTheme.paywallPinkText
  907. )
  908. trialBadgeView = badge
  909. addSubview(badge, positioned: .above, relativeTo: nil)
  910. NSLayoutConstraint.activate([
  911. badge.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
  912. badge.topAnchor.constraint(equalTo: topAnchor, constant: 8),
  913. ])
  914. }
  915. trialBadgeView?.layer?.zPosition = 10
  916. } else {
  917. trialBadgeView?.isHidden = true
  918. }
  919. }
  920. func refreshAppearance() {
  921. updateAppearance()
  922. subtitleLabel.refreshThemeLabelColor()
  923. if isHovered {
  924. applyHoverLift(true)
  925. }
  926. }
  927. private func setHovered(_ hovering: Bool) {
  928. isHovered = hovering
  929. applyHoverLift(hovering)
  930. updateAppearance()
  931. }
  932. private func updateAppearance() {
  933. let trialPlan = PaywallConfigService.shared.config.trialEligiblePlan
  934. let titleColor = isChosen && plan == trialPlan ? AppTheme.green : AppTheme.paywallAccent
  935. titleLabel.textColor = titleColor
  936. priceLabel.textColor = titleColor
  937. layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
  938. if isChosen {
  939. layer?.borderWidth = 2
  940. layer?.borderColor = AppTheme.green.cgColor
  941. } else if isHovered {
  942. layer?.borderWidth = 2
  943. let hoverBorder = AppTheme.paywallBorder.blended(withFraction: 0.35, of: AppTheme.paywallAccent)
  944. ?? AppTheme.paywallBorder
  945. layer?.borderColor = hoverBorder.cgColor
  946. } else {
  947. layer?.borderWidth = 1.5
  948. layer?.borderColor = AppTheme.paywallBorder.cgColor
  949. }
  950. }
  951. override func mouseUp(with event: NSEvent) {
  952. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  953. onSelect?()
  954. }
  955. override func resetCursorRects() {
  956. addCursorRect(bounds, cursor: .pointingHand)
  957. }
  958. }
  959. // MARK: - Footer Link
  960. private final class PaywallFooterLink: NSButton, AppearanceRefreshable {
  961. private var hoverTracker: HoverTracker?
  962. private var isHovered = false
  963. init(title: String) {
  964. super.init(frame: .zero)
  965. updateTitle(title)
  966. isBordered = false
  967. bezelStyle = .inline
  968. font = AppTheme.regularFont(size: 11)
  969. translatesAutoresizingMaskIntoConstraints = false
  970. refreshAppearance()
  971. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  972. self?.isHovered = hovering
  973. self?.refreshAppearance()
  974. }
  975. }
  976. @available(*, unavailable)
  977. required init?(coder: NSCoder) { nil }
  978. func updateTitle(_ title: String) {
  979. self.title = title
  980. invalidateIntrinsicContentSize()
  981. needsDisplay = true
  982. }
  983. func refreshAppearance() {
  984. contentTintColor = isHovered ? AppTheme.textPrimary : AppTheme.textSecondary
  985. }
  986. override func resetCursorRects() {
  987. addCursorRect(bounds, cursor: .pointingHand)
  988. }
  989. }
  990. // MARK: - Footer Trust Item
  991. private final class PaywallTrustItemView: NSView, AppearanceRefreshable {
  992. private let iconContainer = NSView()
  993. private let icon = NSImageView()
  994. private let titleLabel: NSTextField
  995. private let subtitleLabel: NSTextField
  996. init(iconName: String, title: String, subtitle: String) {
  997. titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.semiboldFont(size: 11))
  998. subtitleLabel = NSTextField.themeLabel(subtitle, style: .secondary, font: AppTheme.regularFont(size: 9))
  999. super.init(frame: .zero)
  1000. translatesAutoresizingMaskIntoConstraints = false
  1001. iconContainer.translatesAutoresizingMaskIntoConstraints = false
  1002. iconContainer.wantsLayer = true
  1003. iconContainer.layer?.cornerRadius = 10
  1004. iconContainer.layer?.masksToBounds = true
  1005. icon.translatesAutoresizingMaskIntoConstraints = false
  1006. if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
  1007. let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  1008. icon.image = image.withSymbolConfiguration(config)
  1009. }
  1010. titleLabel.lineBreakMode = .byTruncatingTail
  1011. titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  1012. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  1013. subtitleLabel.lineBreakMode = .byTruncatingTail
  1014. subtitleLabel.maximumNumberOfLines = 2
  1015. subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  1016. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  1017. addSubview(iconContainer)
  1018. iconContainer.addSubview(icon)
  1019. addSubview(titleLabel)
  1020. addSubview(subtitleLabel)
  1021. NSLayoutConstraint.activate([
  1022. heightAnchor.constraint(equalToConstant: Self.preferredHeight),
  1023. iconContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
  1024. iconContainer.topAnchor.constraint(equalTo: topAnchor),
  1025. iconContainer.widthAnchor.constraint(equalToConstant: 20),
  1026. iconContainer.heightAnchor.constraint(equalToConstant: 20),
  1027. icon.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  1028. icon.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
  1029. icon.widthAnchor.constraint(equalToConstant: 12),
  1030. icon.heightAnchor.constraint(equalToConstant: 12),
  1031. titleLabel.leadingAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: 8),
  1032. titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 1),
  1033. titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
  1034. subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  1035. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2),
  1036. subtitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
  1037. subtitleLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
  1038. ])
  1039. setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  1040. setContentHuggingPriority(.defaultLow, for: .horizontal)
  1041. setContentHuggingPriority(.required, for: .vertical)
  1042. setContentCompressionResistancePriority(.required, for: .vertical)
  1043. refreshAppearance()
  1044. }
  1045. fileprivate static let preferredHeight: CGFloat = 34
  1046. func update(iconName: String, title: String, subtitle: String) {
  1047. titleLabel.stringValue = title
  1048. subtitleLabel.stringValue = subtitle
  1049. if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
  1050. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  1051. icon.image = image.withSymbolConfiguration(symbolConfig)
  1052. }
  1053. }
  1054. @available(*, unavailable)
  1055. required init?(coder: NSCoder) { nil }
  1056. func refreshAppearance() {
  1057. iconContainer.layer?.backgroundColor = AppTheme.paywallTrustIconBackground.cgColor
  1058. icon.contentTintColor = AppTheme.paywallIconAccent
  1059. titleLabel.refreshThemeLabelColor()
  1060. subtitleLabel.refreshThemeLabelColor()
  1061. }
  1062. }
  1063. // MARK: - Products Error
  1064. private final class PaywallProductsErrorView: NSView, AppearanceRefreshable {
  1065. var onRetry: (() -> Void)?
  1066. private let messageLabel = NSTextField(wrappingLabelWithString: "")
  1067. private let retryButton = PaywallFooterLink(title: "")
  1068. init() {
  1069. super.init(frame: .zero)
  1070. translatesAutoresizingMaskIntoConstraints = false
  1071. messageLabel.font = AppTheme.regularFont(size: 12)
  1072. messageLabel.textColor = AppTheme.textSecondary
  1073. messageLabel.alignment = .center
  1074. messageLabel.maximumNumberOfLines = 3
  1075. messageLabel.lineBreakMode = .byWordWrapping
  1076. messageLabel.cell?.wraps = true
  1077. messageLabel.cell?.isScrollable = false
  1078. messageLabel.translatesAutoresizingMaskIntoConstraints = false
  1079. retryButton.target = self
  1080. retryButton.action = #selector(retryTapped)
  1081. addSubview(messageLabel)
  1082. addSubview(retryButton)
  1083. NSLayoutConstraint.activate([
  1084. messageLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
  1085. messageLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
  1086. messageLabel.topAnchor.constraint(equalTo: topAnchor),
  1087. retryButton.centerXAnchor.constraint(equalTo: centerXAnchor),
  1088. retryButton.topAnchor.constraint(equalTo: messageLabel.bottomAnchor, constant: 8),
  1089. retryButton.bottomAnchor.constraint(equalTo: bottomAnchor),
  1090. ])
  1091. }
  1092. @available(*, unavailable)
  1093. required init?(coder: NSCoder) { nil }
  1094. func update(message: String, isRetrying: Bool) {
  1095. let messages = PaywallConfigService.shared.config.messages
  1096. messageLabel.stringValue = message
  1097. retryButton.isHidden = false
  1098. retryButton.isEnabled = !isRetrying
  1099. retryButton.updateTitle(isRetrying ? messages.retrying : messages.retry)
  1100. }
  1101. func refreshAppearance() {
  1102. messageLabel.textColor = AppTheme.textSecondary
  1103. retryButton.refreshAppearance()
  1104. }
  1105. @objc private func retryTapped() {
  1106. onRetry?()
  1107. }
  1108. }
  1109. // MARK: - Close Button
  1110. private final class PaywallCloseButton: NSButton, AppearanceRefreshable {
  1111. private var hoverTracker: HoverTracker?
  1112. private var isHovered = false
  1113. init() {
  1114. super.init(frame: .zero)
  1115. isBordered = false
  1116. bezelStyle = .inline
  1117. translatesAutoresizingMaskIntoConstraints = false
  1118. wantsLayer = true
  1119. layer?.cornerRadius = 15
  1120. layer?.borderWidth = 1.5
  1121. if let image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close") {
  1122. let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  1123. self.image = image.withSymbolConfiguration(config)
  1124. }
  1125. refreshAppearance()
  1126. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  1127. self?.setHovered(hovering)
  1128. }
  1129. }
  1130. @available(*, unavailable)
  1131. required init?(coder: NSCoder) { nil }
  1132. func refreshAppearance() {
  1133. updateAppearance()
  1134. }
  1135. private func setHovered(_ hovering: Bool) {
  1136. isHovered = hovering
  1137. animateHover {
  1138. updateAppearance()
  1139. layer?.transform = hovering
  1140. ? CATransform3DMakeScale(1.06, 1.06, 1)
  1141. : CATransform3DIdentity
  1142. }
  1143. }
  1144. private func updateAppearance() {
  1145. if isHovered {
  1146. layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  1147. layer?.borderColor = AppTheme.paywallAccent.withAlphaComponent(0.45).cgColor
  1148. contentTintColor = AppTheme.textPrimary
  1149. } else {
  1150. layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
  1151. layer?.borderColor = AppTheme.paywallBorder.cgColor
  1152. contentTintColor = AppTheme.textSecondary
  1153. }
  1154. }
  1155. override func resetCursorRects() {
  1156. addCursorRect(bounds, cursor: .pointingHand)
  1157. }
  1158. }
  1159. // MARK: - CTA Button
  1160. private final class PaywallCTAButton: NSButton, AppearanceRefreshable {
  1161. private var hoverTracker: HoverTracker?
  1162. private let progressIndicator = NSProgressIndicator()
  1163. init() {
  1164. super.init(frame: .zero)
  1165. isBordered = false
  1166. wantsLayer = true
  1167. layer?.cornerRadius = 12
  1168. font = AppTheme.semiboldFont(size: 15)
  1169. translatesAutoresizingMaskIntoConstraints = false
  1170. progressIndicator.style = .spinning
  1171. progressIndicator.controlSize = .small
  1172. progressIndicator.isDisplayedWhenStopped = false
  1173. progressIndicator.translatesAutoresizingMaskIntoConstraints = false
  1174. progressIndicator.isHidden = true
  1175. addSubview(progressIndicator)
  1176. NSLayoutConstraint.activate([
  1177. progressIndicator.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
  1178. progressIndicator.centerYAnchor.constraint(equalTo: centerYAnchor),
  1179. ])
  1180. refreshAppearance()
  1181. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  1182. self?.setHovered(hovering)
  1183. }
  1184. }
  1185. @available(*, unavailable)
  1186. required init?(coder: NSCoder) { nil }
  1187. func setShowsProgress(_ showsProgress: Bool) {
  1188. progressIndicator.isHidden = !showsProgress
  1189. if showsProgress {
  1190. progressIndicator.startAnimation(nil)
  1191. } else {
  1192. progressIndicator.stopAnimation(nil)
  1193. }
  1194. }
  1195. func refreshAppearance() {
  1196. layer?.backgroundColor = AppTheme.paywallCTABackground.cgColor
  1197. contentTintColor = AppTheme.paywallCTAForeground
  1198. }
  1199. private func setHovered(_ hovering: Bool) {
  1200. let base = AppTheme.paywallCTABackground
  1201. let color = hovering ? base.blended(withFraction: 0.12, of: .black) ?? base : base
  1202. animateHover {
  1203. layer?.backgroundColor = color.cgColor
  1204. layer?.transform = hovering
  1205. ? CATransform3DMakeScale(1.02, 1.02, 1)
  1206. : CATransform3DIdentity
  1207. }
  1208. }
  1209. override func resetCursorRects() {
  1210. addCursorRect(bounds, cursor: .pointingHand)
  1211. }
  1212. }
  1213. // MARK: - Main Paywall Card
  1214. final class PaywallView: NSView, AppearanceRefreshable {
  1215. var onClose: (() -> Void)?
  1216. private var selectedPlan: PaywallPlan = .yearly
  1217. private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
  1218. private let ctaButton = PaywallCTAButton()
  1219. private let renewalDisclosureLabel = NSTextField(wrappingLabelWithString: "")
  1220. private let securePaymentLabel = NSTextField(labelWithString: "")
  1221. private let continueFreePlanLink = PaywallFooterLink(title: "")
  1222. private let manageSubscriptionLink = PaywallFooterLink(title: "")
  1223. private let restoreLink = PaywallFooterLink(title: "")
  1224. private let privacyLink = PaywallFooterLink(title: "")
  1225. private let termsLink = PaywallFooterLink(title: "")
  1226. private let supportLink = PaywallFooterLink(title: "")
  1227. private let premiumCloseButton = PaywallCloseButton()
  1228. private var primaryFooterLinkCell: NSView?
  1229. private var leftPanelTitle: NSTextField!
  1230. private var rightTitle: NSTextField!
  1231. private var rightSubtitle: NSTextField!
  1232. private var featuresStack: NSStackView!
  1233. private var featureRows: [PaywallFeatureRow] = []
  1234. private var trustItemViews: [PaywallTrustItemView] = []
  1235. private var trustStack: NSStackView!
  1236. private var productsErrorView: PaywallProductsErrorView!
  1237. private var plansStack: NSStackView!
  1238. private var plansStackHeightConstraint: NSLayoutConstraint!
  1239. private var plansAreaViewHeightConstraint: NSLayoutConstraint!
  1240. private var productsErrorHeightConstraint: NSLayoutConstraint!
  1241. private var storeObservers: [NSObjectProtocol] = []
  1242. init() {
  1243. super.init(frame: .zero)
  1244. translatesAutoresizingMaskIntoConstraints = false
  1245. wantsLayer = true
  1246. layer?.cornerRadius = 0
  1247. setup()
  1248. observeStoreUpdates()
  1249. refreshConfigDisplay()
  1250. refreshProductDisplay()
  1251. refreshAppearance()
  1252. }
  1253. deinit {
  1254. storeObservers.forEach { NotificationCenter.default.removeObserver($0) }
  1255. }
  1256. private func observeStoreUpdates() {
  1257. let center = NotificationCenter.default
  1258. storeObservers = [
  1259. center.addObserver(forName: .storeProductsDidUpdate, object: nil, queue: .main) { [weak self] _ in
  1260. self?.refreshProductDisplay()
  1261. },
  1262. center.addObserver(forName: .storeStateDidChange, object: nil, queue: .main) { [weak self] _ in
  1263. self?.refreshProductDisplay()
  1264. self?.selectTrialEligiblePlanIfAvailable()
  1265. },
  1266. center.addObserver(forName: .premiumStatusDidChange, object: nil, queue: .main) { [weak self] _ in
  1267. self?.refreshProductDisplay()
  1268. },
  1269. center.addObserver(forName: .paywallConfigDidUpdate, object: nil, queue: .main) { [weak self] _ in
  1270. self?.refreshConfigDisplay()
  1271. self?.refreshProductDisplay()
  1272. },
  1273. ]
  1274. }
  1275. private func selectTrialEligiblePlanIfAvailable() {
  1276. let store = StoreManager.shared
  1277. let config = paywallConfig
  1278. guard let eligiblePlan = config.trialEligiblePlan,
  1279. store.trialDisplay(for: eligiblePlan) != nil else {
  1280. return
  1281. }
  1282. selectPlan(eligiblePlan)
  1283. }
  1284. private var paywallConfig: PaywallConfig {
  1285. PaywallConfigService.shared.config
  1286. }
  1287. private func refreshConfigDisplay() {
  1288. let config = paywallConfig
  1289. leftPanelTitle?.stringValue = config.leftPanelTitle
  1290. rightTitle?.stringValue = config.rightTitle
  1291. rightSubtitle?.stringValue = config.rightSubtitle
  1292. syncFeatureRows(with: config.features)
  1293. syncTrustItems(with: config.trustItems)
  1294. continueFreePlanLink.updateTitle(config.footer.continueFree)
  1295. manageSubscriptionLink.updateTitle(config.footer.manageSubscription)
  1296. restoreLink.updateTitle(config.footer.restorePurchase)
  1297. privacyLink.updateTitle(config.footer.privacyPolicy)
  1298. termsLink.updateTitle(config.footer.termsOfService)
  1299. supportLink.updateTitle(config.footer.support)
  1300. refreshSecurePaymentNote()
  1301. }
  1302. private func syncFeatureRows(with features: [String]) {
  1303. guard let featuresStack else { return }
  1304. while featureRows.count < features.count {
  1305. let row = PaywallFeatureRow(text: "")
  1306. featureRows.append(row)
  1307. featuresStack.addArrangedSubview(row)
  1308. }
  1309. while featureRows.count > features.count {
  1310. let row = featureRows.removeLast()
  1311. featuresStack.removeArrangedSubview(row)
  1312. row.removeFromSuperview()
  1313. }
  1314. for (index, text) in features.enumerated() {
  1315. featureRows[index].updateText(text)
  1316. }
  1317. }
  1318. private func syncTrustItems(with items: [PaywallConfig.TrustItem]) {
  1319. for (index, item) in items.enumerated() where index < trustItemViews.count {
  1320. trustItemViews[index].update(
  1321. iconName: item.icon,
  1322. title: item.title,
  1323. subtitle: item.subtitle
  1324. )
  1325. }
  1326. }
  1327. private func refreshProductDisplay() {
  1328. let store = StoreManager.shared
  1329. let config = paywallConfig
  1330. let availablePlans = store.availablePlans
  1331. syncPlanCards(with: availablePlans)
  1332. for (plan, card) in planCards {
  1333. let product = store.product(for: plan)
  1334. card.updateDisplay(
  1335. product: product,
  1336. config: config,
  1337. trial: store.trialDisplay(for: plan)
  1338. )
  1339. }
  1340. ensureValidSelectedPlan(from: availablePlans)
  1341. refreshPlansLayout(planCount: availablePlans.count)
  1342. ctaButton.title = selectedPlan.localizedCTATitle(
  1343. from: store.product(for: selectedPlan),
  1344. config: config,
  1345. trial: store.trialDisplay(for: selectedPlan)
  1346. )
  1347. updateRenewalDisclosure(
  1348. selectedPlan.localizedRenewalDisclosure(
  1349. from: store.product(for: selectedPlan),
  1350. config: config,
  1351. trial: store.trialDisplay(for: selectedPlan)
  1352. )
  1353. )
  1354. refreshTrustItemsForSelectedPlan()
  1355. refreshProductsErrorState()
  1356. refreshPurchaseState()
  1357. refreshSecurePaymentNote()
  1358. selectTrialEligiblePlanIfAvailable()
  1359. }
  1360. private func syncPlanCards(with plans: [PaywallPlan]) {
  1361. guard let plansStack else { return }
  1362. let removedPlans = planCards.keys.filter { !plans.contains($0) }
  1363. for plan in removedPlans {
  1364. guard let card = planCards.removeValue(forKey: plan) else { continue }
  1365. plansStack.removeArrangedSubview(card)
  1366. card.removeFromSuperview()
  1367. }
  1368. for plan in plans where planCards[plan] == nil {
  1369. let card = PaywallPlanCard(plan: plan)
  1370. card.isChosen = plan == selectedPlan
  1371. card.onSelect = { [weak self] in self?.selectPlan(plan) }
  1372. planCards[plan] = card
  1373. }
  1374. for view in plansStack.arrangedSubviews {
  1375. plansStack.removeArrangedSubview(view)
  1376. }
  1377. for plan in plans {
  1378. if let card = planCards[plan] {
  1379. plansStack.addArrangedSubview(card)
  1380. }
  1381. }
  1382. }
  1383. private func ensureValidSelectedPlan(from availablePlans: [PaywallPlan]) {
  1384. guard !availablePlans.isEmpty else { return }
  1385. guard !availablePlans.contains(selectedPlan) else { return }
  1386. let preferred = paywallConfig.trialEligiblePlan
  1387. .flatMap { trialPlan in
  1388. availablePlans.contains(trialPlan) && StoreManager.shared.trialDisplay(for: trialPlan) != nil
  1389. ? trialPlan
  1390. : nil
  1391. }
  1392. ?? availablePlans.first(where: { $0 == .yearly })
  1393. ?? availablePlans[0]
  1394. selectPlan(preferred)
  1395. }
  1396. private func refreshPlansLayout(planCount: Int) {
  1397. let height = plansAreaHeight(for: planCount)
  1398. plansStackHeightConstraint?.constant = height
  1399. plansAreaViewHeightConstraint?.constant = height
  1400. }
  1401. private func plansAreaHeight(for planCount: Int) -> CGFloat {
  1402. let cardHeight: CGFloat = 86
  1403. let spacing: CGFloat = 12
  1404. let count = CGFloat(max(planCount, 0))
  1405. guard count > 0 else { return 0 }
  1406. return count * cardHeight + max(0, count - 1) * spacing
  1407. }
  1408. private func refreshSecurePaymentNote() {
  1409. let config = paywallConfig
  1410. securePaymentLabel.stringValue = selectedPlan == .lifetime
  1411. ? config.lifetimeSecurePaymentNote
  1412. : config.securePaymentNote
  1413. }
  1414. private func refreshProductsErrorState() {
  1415. let store = StoreManager.shared
  1416. let hasLoadFailure = store.productLoadError != nil && !store.hasAvailablePlans
  1417. let isRetrying = hasLoadFailure && store.isLoadingProducts
  1418. productsErrorView.isHidden = !hasLoadFailure
  1419. plansStack.isHidden = hasLoadFailure || store.availablePlans.isEmpty
  1420. let visiblePlanCount = hasLoadFailure ? 0 : store.availablePlans.count
  1421. refreshPlansLayout(planCount: visiblePlanCount)
  1422. productsErrorHeightConstraint.constant = hasLoadFailure ? productsErrorAreaHeight : 0
  1423. if hasLoadFailure, let error = store.productLoadError {
  1424. productsErrorView.update(message: error, isRetrying: isRetrying)
  1425. }
  1426. }
  1427. private var productsErrorAreaHeight: CGFloat { 86 }
  1428. private func refreshPurchaseState() {
  1429. let store = StoreManager.shared
  1430. let hasProductLoadFailure = store.productLoadError != nil && !store.hasAvailablePlans
  1431. let isProductReady = store.product(for: selectedPlan) != nil
  1432. let isBusy = store.isPurchasing || store.isLoadingProducts
  1433. let isDisabled = isBusy || !isProductReady || hasProductLoadFailure
  1434. ctaButton.isEnabled = !isDisabled
  1435. ctaButton.alphaValue = isDisabled ? 0.65 : 1
  1436. ctaButton.setShowsProgress(store.purchasingPlan == selectedPlan)
  1437. restoreLink.isEnabled = !store.isRestoringPurchases
  1438. restoreLink.updateTitle(
  1439. store.isRestoringPurchases
  1440. ? paywallConfig.messages.retrying
  1441. : paywallConfig.footer.restorePurchase
  1442. )
  1443. refreshPrimaryFooterLink()
  1444. }
  1445. private func refreshPrimaryFooterLink() {
  1446. let store = StoreManager.shared
  1447. let showContinue = !store.isPro
  1448. let showManage = store.hasActiveSubscription
  1449. continueFreePlanLink.isHidden = !showContinue
  1450. manageSubscriptionLink.isHidden = !showManage
  1451. premiumCloseButton.isHidden = !store.isPro
  1452. primaryFooterLinkCell?.isHidden = !showContinue && !showManage
  1453. primaryFooterLinkCell?.needsLayout = true
  1454. primaryFooterLinkCell?.layoutSubtreeIfNeeded()
  1455. }
  1456. private func refreshTrustItemsForSelectedPlan() {
  1457. guard trustItemViews.count > 1, paywallConfig.trustItems.count > 1 else { return }
  1458. let cancelItem = paywallConfig.trustItems[1]
  1459. if selectedPlan == .lifetime {
  1460. let item = paywallConfig.lifetimeTrustItem
  1461. trustItemViews[1].update(
  1462. iconName: item.icon,
  1463. title: item.title,
  1464. subtitle: item.subtitle
  1465. )
  1466. } else {
  1467. trustItemViews[1].update(
  1468. iconName: cancelItem.icon,
  1469. title: cancelItem.title,
  1470. subtitle: cancelItem.subtitle
  1471. )
  1472. }
  1473. }
  1474. func refreshStoreState() {
  1475. refreshProductDisplay()
  1476. }
  1477. override func layout() {
  1478. super.layout()
  1479. let width = renewalDisclosureLabel.bounds.width
  1480. guard width > 0, renewalDisclosureLabel.preferredMaxLayoutWidth != width else { return }
  1481. renewalDisclosureLabel.preferredMaxLayoutWidth = width
  1482. renewalDisclosureLabel.invalidateIntrinsicContentSize()
  1483. }
  1484. private func updateRenewalDisclosure(_ text: String) {
  1485. renewalDisclosureLabel.stringValue = text
  1486. renewalDisclosureLabel.invalidateIntrinsicContentSize()
  1487. needsLayout = true
  1488. }
  1489. func refreshAppearance() {
  1490. layer?.backgroundColor = AppTheme.paywallBackground.cgColor
  1491. leftPanelTitle?.refreshThemeLabelColor()
  1492. rightTitle?.refreshThemeLabelColor()
  1493. rightSubtitle?.refreshThemeLabelColor()
  1494. renewalDisclosureLabel.textColor = AppTheme.textSecondary
  1495. securePaymentLabel.textColor = AppTheme.textSecondary
  1496. trustStack?.layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
  1497. trustStack?.layer?.borderColor = AppTheme.paywallBorder.cgColor
  1498. ctaButton.refreshAppearance()
  1499. subviews.forEach { $0.refreshAppearanceRecursively() }
  1500. }
  1501. @available(*, unavailable)
  1502. required init?(coder: NSCoder) { nil }
  1503. private func setup() {
  1504. let leftPanel = makeLeftPanel()
  1505. let rightPanel = makeRightPanel()
  1506. premiumCloseButton.target = self
  1507. premiumCloseButton.action = #selector(premiumCloseTapped)
  1508. premiumCloseButton.toolTip = paywallConfig.closeButtonHelp
  1509. premiumCloseButton.isHidden = true
  1510. addSubview(leftPanel)
  1511. addSubview(rightPanel)
  1512. addSubview(premiumCloseButton)
  1513. NSLayoutConstraint.activate([
  1514. leftPanel.leadingAnchor.constraint(equalTo: leadingAnchor),
  1515. leftPanel.topAnchor.constraint(equalTo: topAnchor),
  1516. leftPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
  1517. leftPanel.widthAnchor.constraint(equalToConstant: 320),
  1518. rightPanel.leadingAnchor.constraint(equalTo: leftPanel.trailingAnchor),
  1519. rightPanel.trailingAnchor.constraint(equalTo: trailingAnchor),
  1520. rightPanel.topAnchor.constraint(equalTo: topAnchor),
  1521. rightPanel.bottomAnchor.constraint(equalTo: bottomAnchor),
  1522. premiumCloseButton.topAnchor.constraint(equalTo: topAnchor, constant: 16),
  1523. premiumCloseButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
  1524. premiumCloseButton.widthAnchor.constraint(equalToConstant: 30),
  1525. premiumCloseButton.heightAnchor.constraint(equalToConstant: 30),
  1526. ])
  1527. }
  1528. private func makeLeftPanel() -> NSView {
  1529. let panel = PaywallLeftPanelView()
  1530. panel.translatesAutoresizingMaskIntoConstraints = false
  1531. let config = paywallConfig
  1532. let title = NSTextField.themeLabel(
  1533. config.leftPanelTitle,
  1534. style: .primary,
  1535. font: AppTheme.semiboldFont(size: 22)
  1536. )
  1537. title.maximumNumberOfLines = 2
  1538. title.translatesAutoresizingMaskIntoConstraints = false
  1539. leftPanelTitle = title
  1540. let featuresStack = NSStackView()
  1541. featuresStack.orientation = .vertical
  1542. featuresStack.spacing = 6
  1543. featuresStack.alignment = .leading
  1544. featuresStack.translatesAutoresizingMaskIntoConstraints = false
  1545. self.featuresStack = featuresStack
  1546. for feature in config.features {
  1547. let row = PaywallFeatureRow(text: feature)
  1548. featureRows.append(row)
  1549. featuresStack.addArrangedSubview(row)
  1550. }
  1551. panel.addSubview(title)
  1552. panel.addSubview(featuresStack)
  1553. NSLayoutConstraint.activate([
  1554. title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  1555. title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
  1556. title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 48),
  1557. featuresStack.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  1558. featuresStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -20),
  1559. featuresStack.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 28),
  1560. ])
  1561. return panel
  1562. }
  1563. private func makeRightPanel() -> NSView {
  1564. let panel = NSView()
  1565. panel.translatesAutoresizingMaskIntoConstraints = false
  1566. let config = paywallConfig
  1567. let title = NSTextField.themeLabel(
  1568. config.rightTitle,
  1569. style: .primary,
  1570. font: AppTheme.semiboldFont(size: 26)
  1571. )
  1572. title.alignment = .center
  1573. title.translatesAutoresizingMaskIntoConstraints = false
  1574. rightTitle = title
  1575. let subtitle = NSTextField.themeLabel(
  1576. config.rightSubtitle,
  1577. style: .secondary,
  1578. font: AppTheme.regularFont(size: 13)
  1579. )
  1580. subtitle.alignment = .center
  1581. subtitle.maximumNumberOfLines = 2
  1582. subtitle.translatesAutoresizingMaskIntoConstraints = false
  1583. rightSubtitle = subtitle
  1584. let plansStack = NSStackView()
  1585. plansStack.orientation = .vertical
  1586. plansStack.spacing = 12
  1587. plansStack.translatesAutoresizingMaskIntoConstraints = false
  1588. self.plansStack = plansStack
  1589. let plansAreaView = NSView()
  1590. plansAreaView.translatesAutoresizingMaskIntoConstraints = false
  1591. let productsErrorView = PaywallProductsErrorView()
  1592. productsErrorView.isHidden = true
  1593. productsErrorView.onRetry = { [weak self] in
  1594. self?.retryProductLoad()
  1595. }
  1596. self.productsErrorView = productsErrorView
  1597. ctaButton.title = selectedPlan.localizedCTATitle(
  1598. from: StoreManager.shared.product(for: selectedPlan),
  1599. config: config,
  1600. trial: StoreManager.shared.trialDisplay(for: selectedPlan)
  1601. )
  1602. ctaButton.target = self
  1603. ctaButton.action = #selector(purchaseTapped)
  1604. ctaButton.translatesAutoresizingMaskIntoConstraints = false
  1605. renewalDisclosureLabel.font = AppTheme.regularFont(size: 10)
  1606. renewalDisclosureLabel.textColor = AppTheme.textSecondary
  1607. renewalDisclosureLabel.alignment = .center
  1608. renewalDisclosureLabel.maximumNumberOfLines = 3
  1609. renewalDisclosureLabel.lineBreakMode = .byWordWrapping
  1610. renewalDisclosureLabel.cell?.wraps = true
  1611. renewalDisclosureLabel.cell?.isScrollable = false
  1612. renewalDisclosureLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  1613. renewalDisclosureLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
  1614. renewalDisclosureLabel.setContentHuggingPriority(.required, for: .vertical)
  1615. renewalDisclosureLabel.translatesAutoresizingMaskIntoConstraints = false
  1616. updateRenewalDisclosure(
  1617. selectedPlan.localizedRenewalDisclosure(
  1618. from: StoreManager.shared.product(for: selectedPlan),
  1619. config: config,
  1620. trial: StoreManager.shared.trialDisplay(for: selectedPlan)
  1621. )
  1622. )
  1623. securePaymentLabel.font = AppTheme.regularFont(size: 10)
  1624. securePaymentLabel.textColor = AppTheme.textSecondary
  1625. securePaymentLabel.alignment = .center
  1626. securePaymentLabel.translatesAutoresizingMaskIntoConstraints = false
  1627. refreshSecurePaymentNote()
  1628. let trustRow = makeTrustRow()
  1629. let footerLinks = makeFooterLinks()
  1630. let footerSpacer = NSView()
  1631. footerSpacer.translatesAutoresizingMaskIntoConstraints = false
  1632. footerSpacer.setContentHuggingPriority(.defaultLow, for: .vertical)
  1633. footerSpacer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  1634. plansAreaView.addSubview(plansStack)
  1635. plansAreaView.addSubview(productsErrorView)
  1636. plansStackHeightConstraint = plansStack.heightAnchor.constraint(equalToConstant: 0)
  1637. productsErrorHeightConstraint = productsErrorView.heightAnchor.constraint(equalToConstant: 0)
  1638. plansAreaViewHeightConstraint = plansAreaView.heightAnchor.constraint(equalToConstant: 0)
  1639. panel.addSubview(title)
  1640. panel.addSubview(subtitle)
  1641. panel.addSubview(plansAreaView)
  1642. panel.addSubview(trustRow)
  1643. panel.addSubview(ctaButton)
  1644. panel.addSubview(renewalDisclosureLabel)
  1645. panel.addSubview(securePaymentLabel)
  1646. panel.addSubview(footerSpacer)
  1647. panel.addSubview(footerLinks)
  1648. NSLayoutConstraint.activate([
  1649. title.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  1650. title.topAnchor.constraint(equalTo: panel.topAnchor, constant: 40),
  1651. title.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  1652. subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  1653. subtitle.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  1654. subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
  1655. plansAreaView.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  1656. plansAreaView.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  1657. plansAreaView.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 24),
  1658. plansAreaViewHeightConstraint,
  1659. plansStack.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
  1660. plansStack.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
  1661. plansStack.topAnchor.constraint(equalTo: plansAreaView.topAnchor),
  1662. plansStackHeightConstraint,
  1663. productsErrorView.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
  1664. productsErrorView.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
  1665. productsErrorView.topAnchor.constraint(equalTo: plansAreaView.topAnchor),
  1666. productsErrorHeightConstraint,
  1667. trustRow.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
  1668. trustRow.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
  1669. trustRow.topAnchor.constraint(equalTo: plansAreaView.bottomAnchor, constant: 18),
  1670. ctaButton.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
  1671. ctaButton.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
  1672. ctaButton.topAnchor.constraint(equalTo: trustRow.bottomAnchor, constant: 16),
  1673. ctaButton.heightAnchor.constraint(equalToConstant: 48),
  1674. renewalDisclosureLabel.leadingAnchor.constraint(equalTo: plansAreaView.leadingAnchor),
  1675. renewalDisclosureLabel.trailingAnchor.constraint(equalTo: plansAreaView.trailingAnchor),
  1676. renewalDisclosureLabel.topAnchor.constraint(equalTo: ctaButton.bottomAnchor, constant: 10),
  1677. securePaymentLabel.centerXAnchor.constraint(equalTo: plansAreaView.centerXAnchor),
  1678. securePaymentLabel.topAnchor.constraint(equalTo: renewalDisclosureLabel.bottomAnchor, constant: 8),
  1679. footerSpacer.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
  1680. footerSpacer.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
  1681. footerSpacer.topAnchor.constraint(equalTo: securePaymentLabel.bottomAnchor, constant: 16),
  1682. footerSpacer.bottomAnchor.constraint(equalTo: footerLinks.topAnchor),
  1683. footerLinks.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  1684. footerLinks.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  1685. footerLinks.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -16),
  1686. ])
  1687. return panel
  1688. }
  1689. private func makeTrustRow() -> NSView {
  1690. let config = paywallConfig
  1691. trustItemViews = config.trustItems.map { item in
  1692. PaywallTrustItemView(
  1693. iconName: item.icon,
  1694. title: item.title,
  1695. subtitle: item.subtitle
  1696. )
  1697. }
  1698. let trustStack = NSStackView(views: trustItemViews)
  1699. trustStack.orientation = .horizontal
  1700. trustStack.distribution = .fillEqually
  1701. trustStack.spacing = 16
  1702. trustStack.alignment = .top
  1703. trustStack.translatesAutoresizingMaskIntoConstraints = false
  1704. trustStack.wantsLayer = true
  1705. trustStack.layer?.cornerRadius = 12
  1706. trustStack.layer?.borderWidth = 1.5
  1707. trustStack.layer?.masksToBounds = true
  1708. trustStack.edgeInsets = NSEdgeInsets(top: 12, left: 14, bottom: 12, right: 14)
  1709. trustStack.translatesAutoresizingMaskIntoConstraints = false
  1710. trustStack.setContentHuggingPriority(.required, for: .vertical)
  1711. trustStack.setContentCompressionResistancePriority(.required, for: .vertical)
  1712. trustStack.heightAnchor.constraint(
  1713. equalToConstant: PaywallTrustItemView.preferredHeight + trustStack.edgeInsets.top + trustStack.edgeInsets.bottom
  1714. ).isActive = true
  1715. self.trustStack = trustStack
  1716. return trustStack
  1717. }
  1718. private func makeFooterLinks() -> NSView {
  1719. let container = NSView()
  1720. container.translatesAutoresizingMaskIntoConstraints = false
  1721. container.setContentHuggingPriority(.required, for: .vertical)
  1722. container.setContentCompressionResistancePriority(.required, for: .vertical)
  1723. let config = paywallConfig
  1724. let separator = NSView()
  1725. separator.translatesAutoresizingMaskIntoConstraints = false
  1726. separator.wantsLayer = true
  1727. separator.layer?.backgroundColor = AppTheme.paywallBorder.cgColor
  1728. continueFreePlanLink.updateTitle(config.footer.continueFree)
  1729. continueFreePlanLink.target = self
  1730. continueFreePlanLink.action = #selector(continueWithFreePlanTapped)
  1731. manageSubscriptionLink.updateTitle(config.footer.manageSubscription)
  1732. manageSubscriptionLink.target = self
  1733. manageSubscriptionLink.action = #selector(manageSubscriptionTapped)
  1734. let primaryCell = makePrimaryFooterLinkCell()
  1735. primaryFooterLinkCell = primaryCell
  1736. refreshPrimaryFooterLink()
  1737. restoreLink.updateTitle(config.footer.restorePurchase)
  1738. restoreLink.target = self
  1739. restoreLink.action = #selector(restoreTapped)
  1740. privacyLink.updateTitle(config.footer.privacyPolicy)
  1741. privacyLink.target = self
  1742. privacyLink.action = #selector(privacyPolicyTapped)
  1743. termsLink.updateTitle(config.footer.termsOfService)
  1744. termsLink.target = self
  1745. termsLink.action = #selector(termsOfServiceTapped)
  1746. supportLink.updateTitle(config.footer.support)
  1747. supportLink.target = self
  1748. supportLink.action = #selector(supportTapped)
  1749. let linkCells = [
  1750. primaryCell,
  1751. makeFooterLinkCell(link: restoreLink),
  1752. makeFooterLinkCell(link: privacyLink),
  1753. makeFooterLinkCell(link: termsLink),
  1754. makeFooterLinkCell(link: supportLink),
  1755. ]
  1756. let linksStack = NSStackView(views: linkCells)
  1757. linksStack.orientation = .horizontal
  1758. linksStack.spacing = 0
  1759. linksStack.alignment = .centerY
  1760. linksStack.distribution = .fillEqually
  1761. linksStack.translatesAutoresizingMaskIntoConstraints = false
  1762. container.addSubview(separator)
  1763. container.addSubview(linksStack)
  1764. NSLayoutConstraint.activate([
  1765. separator.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  1766. separator.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  1767. separator.topAnchor.constraint(equalTo: container.topAnchor),
  1768. separator.heightAnchor.constraint(equalToConstant: 1),
  1769. linksStack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  1770. linksStack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  1771. linksStack.topAnchor.constraint(equalTo: separator.bottomAnchor, constant: 10),
  1772. linksStack.bottomAnchor.constraint(equalTo: container.bottomAnchor),
  1773. ])
  1774. return container
  1775. }
  1776. private func makePrimaryFooterLinkCell() -> NSView {
  1777. let cell = NSView()
  1778. cell.translatesAutoresizingMaskIntoConstraints = false
  1779. cell.setContentHuggingPriority(.required, for: .vertical)
  1780. cell.setContentCompressionResistancePriority(.required, for: .vertical)
  1781. for link in [continueFreePlanLink, manageSubscriptionLink] {
  1782. cell.addSubview(link)
  1783. NSLayoutConstraint.activate([
  1784. link.centerXAnchor.constraint(equalTo: cell.centerXAnchor),
  1785. link.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
  1786. link.topAnchor.constraint(equalTo: cell.topAnchor),
  1787. link.bottomAnchor.constraint(equalTo: cell.bottomAnchor),
  1788. ])
  1789. }
  1790. return cell
  1791. }
  1792. private func makeFooterLinkCell(link: PaywallFooterLink) -> NSView {
  1793. let cell = NSView()
  1794. cell.translatesAutoresizingMaskIntoConstraints = false
  1795. cell.setContentHuggingPriority(.required, for: .vertical)
  1796. cell.setContentCompressionResistancePriority(.required, for: .vertical)
  1797. cell.addSubview(link)
  1798. NSLayoutConstraint.activate([
  1799. link.centerXAnchor.constraint(equalTo: cell.centerXAnchor),
  1800. link.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
  1801. link.topAnchor.constraint(equalTo: cell.topAnchor),
  1802. link.bottomAnchor.constraint(equalTo: cell.bottomAnchor),
  1803. ])
  1804. return cell
  1805. }
  1806. private func selectPlan(_ plan: PaywallPlan) {
  1807. selectedPlan = plan
  1808. for (key, card) in planCards {
  1809. card.isChosen = key == plan
  1810. }
  1811. ctaButton.title = plan.localizedCTATitle(
  1812. from: StoreManager.shared.product(for: plan),
  1813. config: paywallConfig,
  1814. trial: StoreManager.shared.trialDisplay(for: plan)
  1815. )
  1816. updateRenewalDisclosure(
  1817. plan.localizedRenewalDisclosure(
  1818. from: StoreManager.shared.product(for: plan),
  1819. config: paywallConfig,
  1820. trial: StoreManager.shared.trialDisplay(for: plan)
  1821. )
  1822. )
  1823. refreshTrustItemsForSelectedPlan()
  1824. refreshPurchaseState()
  1825. refreshSecurePaymentNote()
  1826. }
  1827. private func retryProductLoad() {
  1828. Task { @MainActor in
  1829. refreshProductsErrorState()
  1830. refreshPurchaseState()
  1831. await StoreManager.shared.loadProducts(presentLoadingUI: true)
  1832. refreshStoreState()
  1833. }
  1834. }
  1835. @objc private func purchaseTapped() {
  1836. Task { @MainActor in
  1837. StoreManager.shared.clearPurchaseError()
  1838. refreshPurchaseState()
  1839. let purchased = await StoreManager.shared.purchase(plan: selectedPlan)
  1840. refreshStoreState()
  1841. if purchased {
  1842. onClose?()
  1843. } else if let error = StoreManager.shared.purchaseError {
  1844. StoreManager.shared.showPurchaseError(error, on: window)
  1845. }
  1846. }
  1847. }
  1848. @objc private func restoreTapped() {
  1849. Task { @MainActor in
  1850. StoreManager.shared.clearPurchaseError()
  1851. refreshPurchaseState()
  1852. let restored = await StoreManager.shared.restorePurchases()
  1853. refreshStoreState()
  1854. if restored {
  1855. onClose?()
  1856. } else if let error = StoreManager.shared.purchaseError {
  1857. StoreManager.shared.showPurchaseError(error, on: window)
  1858. }
  1859. }
  1860. }
  1861. @objc private func continueWithFreePlanTapped() {
  1862. onClose?()
  1863. }
  1864. @objc private func premiumCloseTapped() {
  1865. onClose?()
  1866. }
  1867. @objc private func manageSubscriptionTapped() {
  1868. StoreManager.shared.showManageSubscriptions()
  1869. }
  1870. @objc private func privacyPolicyTapped() {
  1871. openExternalLink(paywallConfig.urls.privacy)
  1872. }
  1873. @objc private func termsOfServiceTapped() {
  1874. openExternalLink(paywallConfig.urls.terms)
  1875. }
  1876. @objc private func supportTapped() {
  1877. openExternalLink(paywallConfig.urls.support)
  1878. }
  1879. private func openExternalLink(_ urlString: String) {
  1880. guard let url = URL(string: urlString) else { return }
  1881. NSWorkspace.shared.open(url)
  1882. }
  1883. }
  1884. // MARK: - Overlay Presenter
  1885. final class PaywallOverlayView: NSView, AppearanceRefreshable {
  1886. var onDismiss: (() -> Void)?
  1887. private let paywallView: PaywallView
  1888. private let blurView = NSVisualEffectView()
  1889. private let backdrop = NSView()
  1890. private let pattern = WavePatternView()
  1891. private let loadingOverlay = NSView()
  1892. private let loadingIndicator = NSProgressIndicator()
  1893. private var acceptsHitTesting = false
  1894. private var storeObservers: [NSObjectProtocol] = []
  1895. init() {
  1896. paywallView = PaywallView()
  1897. super.init(frame: .zero)
  1898. translatesAutoresizingMaskIntoConstraints = false
  1899. setup()
  1900. observeStoreUpdates()
  1901. refreshAppearance()
  1902. refreshLoadingOverlay()
  1903. }
  1904. deinit {
  1905. storeObservers.forEach { NotificationCenter.default.removeObserver($0) }
  1906. }
  1907. private func observeStoreUpdates() {
  1908. let center = NotificationCenter.default
  1909. storeObservers = [
  1910. center.addObserver(forName: .storeStateDidChange, object: nil, queue: .main) { [weak self] _ in
  1911. self?.refreshLoadingOverlay()
  1912. },
  1913. center.addObserver(forName: .storeProductsDidUpdate, object: nil, queue: .main) { [weak self] _ in
  1914. self?.refreshLoadingOverlay()
  1915. },
  1916. ]
  1917. }
  1918. private func refreshLoadingOverlay() {
  1919. let store = StoreManager.shared
  1920. let shouldShow = store.isLoadingProducts && !store.hasAvailablePlans && store.productLoadError == nil
  1921. loadingOverlay.isHidden = !shouldShow
  1922. if shouldShow {
  1923. loadingIndicator.startAnimation(nil)
  1924. } else {
  1925. loadingIndicator.stopAnimation(nil)
  1926. }
  1927. }
  1928. func refreshAppearance() {
  1929. backdrop.layer?.backgroundColor = AppTheme.paywallOverlayBackdrop.cgColor
  1930. blurView.material = AppSettings.darkModeEnabled ? .hudWindow : .underWindowBackground
  1931. pattern.refreshAppearance()
  1932. paywallView.refreshAppearance()
  1933. }
  1934. func refreshStoreState() {
  1935. paywallView.refreshStoreState()
  1936. refreshLoadingOverlay()
  1937. }
  1938. @available(*, unavailable)
  1939. required init?(coder: NSCoder) { nil }
  1940. override func hitTest(_ point: NSPoint) -> NSView? {
  1941. guard acceptsHitTesting else { return nil }
  1942. return super.hitTest(point)
  1943. }
  1944. private func setup() {
  1945. blurView.translatesAutoresizingMaskIntoConstraints = false
  1946. blurView.material = .underWindowBackground
  1947. blurView.blendingMode = .withinWindow
  1948. blurView.state = .active
  1949. backdrop.translatesAutoresizingMaskIntoConstraints = false
  1950. backdrop.wantsLayer = true
  1951. backdrop.layer?.backgroundColor = NSColor(calibratedWhite: 0.15, alpha: 0.22).cgColor
  1952. pattern.translatesAutoresizingMaskIntoConstraints = false
  1953. pattern.alphaValue = 0.35
  1954. paywallView.translatesAutoresizingMaskIntoConstraints = false
  1955. paywallView.onClose = { [weak self] in self?.dismiss() }
  1956. loadingOverlay.translatesAutoresizingMaskIntoConstraints = false
  1957. loadingOverlay.wantsLayer = true
  1958. loadingOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.08).cgColor
  1959. loadingOverlay.isHidden = true
  1960. loadingIndicator.style = .spinning
  1961. loadingIndicator.controlSize = .large
  1962. loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
  1963. loadingOverlay.addSubview(loadingIndicator)
  1964. addSubview(blurView)
  1965. addSubview(backdrop)
  1966. backdrop.addSubview(pattern)
  1967. addSubview(paywallView)
  1968. addSubview(loadingOverlay)
  1969. NSLayoutConstraint.activate([
  1970. blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
  1971. blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
  1972. blurView.topAnchor.constraint(equalTo: topAnchor),
  1973. blurView.bottomAnchor.constraint(equalTo: bottomAnchor),
  1974. backdrop.leadingAnchor.constraint(equalTo: leadingAnchor),
  1975. backdrop.trailingAnchor.constraint(equalTo: trailingAnchor),
  1976. backdrop.topAnchor.constraint(equalTo: topAnchor),
  1977. backdrop.bottomAnchor.constraint(equalTo: bottomAnchor),
  1978. pattern.leadingAnchor.constraint(equalTo: backdrop.leadingAnchor),
  1979. pattern.trailingAnchor.constraint(equalTo: backdrop.trailingAnchor),
  1980. pattern.topAnchor.constraint(equalTo: backdrop.topAnchor),
  1981. pattern.bottomAnchor.constraint(equalTo: backdrop.bottomAnchor),
  1982. paywallView.leadingAnchor.constraint(equalTo: leadingAnchor),
  1983. paywallView.trailingAnchor.constraint(equalTo: trailingAnchor),
  1984. paywallView.topAnchor.constraint(equalTo: topAnchor),
  1985. paywallView.bottomAnchor.constraint(equalTo: bottomAnchor),
  1986. loadingOverlay.leadingAnchor.constraint(equalTo: leadingAnchor),
  1987. loadingOverlay.trailingAnchor.constraint(equalTo: trailingAnchor),
  1988. loadingOverlay.topAnchor.constraint(equalTo: topAnchor),
  1989. loadingOverlay.bottomAnchor.constraint(equalTo: bottomAnchor),
  1990. loadingIndicator.centerXAnchor.constraint(equalTo: loadingOverlay.centerXAnchor),
  1991. loadingIndicator.centerYAnchor.constraint(equalTo: loadingOverlay.centerYAnchor),
  1992. ])
  1993. }
  1994. func present(in parent: NSView) {
  1995. guard superview == nil else { return }
  1996. if let window = parent.window,
  1997. let windowFrameView = window.contentView?.superview {
  1998. windowFrameView.addSubview(self)
  1999. NSLayoutConstraint.activate([
  2000. leadingAnchor.constraint(equalTo: windowFrameView.leadingAnchor),
  2001. trailingAnchor.constraint(equalTo: windowFrameView.trailingAnchor),
  2002. topAnchor.constraint(equalTo: windowFrameView.topAnchor),
  2003. bottomAnchor.constraint(equalTo: windowFrameView.bottomAnchor),
  2004. ])
  2005. } else {
  2006. parent.addSubview(self)
  2007. NSLayoutConstraint.activate([
  2008. leadingAnchor.constraint(equalTo: parent.leadingAnchor),
  2009. trailingAnchor.constraint(equalTo: parent.trailingAnchor),
  2010. topAnchor.constraint(equalTo: parent.topAnchor),
  2011. bottomAnchor.constraint(equalTo: parent.bottomAnchor),
  2012. ])
  2013. }
  2014. alphaValue = 1
  2015. acceptsHitTesting = true
  2016. paywallView.refreshStoreState()
  2017. Task { @MainActor in
  2018. await StoreManager.shared.ensurePaywallStoreDataLoaded()
  2019. paywallView.refreshStoreState()
  2020. refreshLoadingOverlay()
  2021. }
  2022. Task { @MainActor in
  2023. await PaywallConfigService.shared.refreshFromRemote()
  2024. paywallView.refreshStoreState()
  2025. }
  2026. }
  2027. func dismiss() {
  2028. acceptsHitTesting = false
  2029. NSAnimationContext.runAnimationGroup({ context in
  2030. context.duration = 0.15
  2031. animator().alphaValue = 0
  2032. }, completionHandler: { [weak self] in
  2033. self?.removeFromSuperview()
  2034. self?.onDismiss?()
  2035. })
  2036. }
  2037. }