Forráskód Böngészése

Merge branch 'storekit-fixes' into main.

Bring StoreKit paywall refactor, App Review fixes, and scanner entitlement correction onto main.
Uzair Tahir 1 hete
szülő
commit
28e820dae3

+ 0 - 0
smart_printer/Info.plist → AppInfo.plist


+ 4 - 7
smart_printer.xcodeproj/project.pbxproj

@@ -6,10 +6,6 @@
 	objectVersion = 77;
 	objects = {
 
-/* Begin PBXBuildFile section */
-		F87934A62FFBD2470043D1E6 /* Products.storekit in Resources */ = {isa = PBXBuildFile; fileRef = F87934A52FFBD2470043D1E6 /* Products.storekit */; };
-/* End PBXBuildFile section */
-
 /* Begin PBXFileReference section */
 		272FF22A2FD19A2200A87B72 /* Printer App - All Printers.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Printer App - All Printers.app"; sourceTree = BUILT_PRODUCTS_DIR; };
 		F87934A52FFBD2470043D1E6 /* Products.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Products.storekit; sourceTree = "<group>"; };
@@ -115,7 +111,6 @@
 			isa = PBXResourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
-				F87934A62FFBD2470043D1E6 /* Products.storekit in Resources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -263,10 +258,11 @@
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
-				INFOPLIST_FILE = smart_printer/Info.plist;
+				INFOPLIST_FILE = AppInfo.plist;
 				INFOPLIST_KEY_CFBundleDisplayName = "Printer App - All Printers";
 				INFOPLIST_KEY_CFBundleName = "Printer App - All Printers";
 				INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
+				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
 				INFOPLIST_KEY_NSContactsUsageDescription = "Printer App - All Printers needs access to your contacts so you can search and print them.";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 MQL Development. All rights reserved.";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
@@ -308,10 +304,11 @@
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
-				INFOPLIST_FILE = smart_printer/Info.plist;
+				INFOPLIST_FILE = AppInfo.plist;
 				INFOPLIST_KEY_CFBundleDisplayName = "Printer App - All Printers";
 				INFOPLIST_KEY_CFBundleName = "Printer App - All Printers";
 				INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
+				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
 				INFOPLIST_KEY_NSContactsUsageDescription = "Printer App - All Printers needs access to your contacts so you can search and print them.";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 MQL Development. All rights reserved.";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;

+ 5 - 6
smart_printer/AppStoreConfig.swift

@@ -1,7 +1,7 @@
 import Foundation
 
 /// Production App Store identifiers for Printer App - All Printers.
-/// Keep these aligned with Xcode, App Store Connect, and `Products.storekit`.
+/// Product IDs are sourced from bundled `paywall.json`.
 enum AppStoreConfig {
     static let displayName = "Printer App - All Printers"
     static let proDisplayName = "\(displayName) Pro"
@@ -24,11 +24,10 @@ enum AppStoreConfig {
     }
 
     enum ProductID {
-        // ASC allows only alphanumeric characters, underscores, and periods.
-        private static let prefix = "com.printer_app_all_printers"
-        static let monthly = "\(prefix).premium.monthly"
-        static let yearly = "\(prefix).premium.yearly"
-        static let lifetime = "\(prefix).premium.lifetime"
+        private static let paywallConfig = PaywallConfig.loadBundled()
+        static let monthly = paywallConfig.plans.monthly.productID
+        static let yearly = paywallConfig.plans.yearly.productID
+        static let lifetime = paywallConfig.plans.lifetime.productID
 
         static let all: Set<String> = [monthly, yearly, lifetime]
     }

+ 2 - 2
smart_printer/DrawPrintView.swift

@@ -45,10 +45,10 @@ enum CanvasTextFontStyle: String, CaseIterable {
             return .systemFont(ofSize: size, weight: .bold)
         case .italic:
             let base = AppTheme.regularFont(size: size)
-            return NSFontManager.shared.convert(base, toHaveTrait: .italicFontMask) ?? base
+            return NSFontManager.shared.convert(base, toHaveTrait: .italicFontMask)
         case .boldItalic:
             let bold = NSFont.systemFont(ofSize: size, weight: .bold)
-            return NSFontManager.shared.convert(bold, toHaveTrait: .italicFontMask) ?? bold
+            return NSFontManager.shared.convert(bold, toHaveTrait: .italicFontMask)
         case .serif:
             return NSFont(name: "Times New Roman", size: size)
                 ?? NSFont(name: "Times-Roman", size: size)

+ 728 - 0
smart_printer/Managers/StoreManager.swift

@@ -0,0 +1,728 @@
+import Cocoa
+import StoreKit
+
+enum PremiumAccessKind: String {
+    case none
+    case subscription
+    case lifetime
+}
+
+enum StoreError: Error {
+    case productNotFound
+    case failedVerification
+}
+
+enum PurchaseOutcome: Sendable {
+    case purchased
+    case cancelled
+    case pending
+}
+
+@MainActor
+final class StoreManager {
+    static let shared = StoreManager()
+
+    private enum ProductLoadError: Error {
+        case timeout
+    }
+
+    private enum StoreSyncError: Error {
+        case timeout
+    }
+
+    private static let cachedPremiumKey = "StoreManager.cachedIsPremium"
+    private static let cachedPremiumPlanKey = "StoreManager.cachedPremiumPlan"
+    private static let cachedHasEverPurchasedKey = "StoreManager.cachedHasEverPurchased"
+    private static let backgroundRefreshInterval: TimeInterval = 120
+
+    private var productCatalog: PaywallConfig { PaywallConfig.loadBundled() }
+
+    private(set) var productsByID: [String: Product] = [:]
+    private(set) var isPremium = UserDefaults.standard.bool(forKey: cachedPremiumKey)
+    private(set) var activePremiumPlan: PaywallPlan?
+    private(set) var hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: cachedHasEverPurchasedKey)
+    private(set) var trialDisplayByPlan: [PaywallPlan: PaywallTrialDisplay] = [:]
+    private(set) var purchaseError: PurchaseError?
+
+    var isPro: Bool { isPremium }
+    var hasActiveSubscription: Bool { activePremiumPlan?.isSubscription == true }
+    var hasLifetimeAccess: Bool { activePremiumPlan?.isLifetime == true }
+    var premiumAccessKind: PremiumAccessKind {
+        switch activePremiumPlan?.kind {
+        case .lifetime: .lifetime
+        case .monthly, .yearly: .subscription
+        case nil: .none
+        }
+    }
+
+    var products: [Product] {
+        availablePlans.compactMap { productsByID[$0.productID] }
+    }
+
+    var availablePlans: [PaywallPlan] {
+        PaywallPlan.displayOrder.filter { productsByID[$0.productID] != nil }
+    }
+
+    var isResolvingEntitlements: Bool { !hasResolvedPremiumStatus }
+    var hasResolvedPremiumStatus = false
+    var hasAvailablePlans: Bool { !availablePlans.isEmpty }
+    var hasAllProductsLoaded: Bool { hasAvailablePlans }
+
+    private(set) var isLoadingProducts = false
+    private(set) var productLoadError: String?
+    private(set) var purchasingPlan: PaywallPlan?
+    private(set) var isRestoringPurchases = false
+
+    var isPurchasing: Bool { purchasingPlan != nil || isRestoringPurchases }
+
+    private var inFlightProductLoad: Task<Void, Never>?
+    private var transactionListener: Task<Void, Never>?
+    private var initialEntitlementTask: Task<Void, Never>?
+    private var entitlementRefreshTask: Task<Void, Never>?
+    private var hasStarted = false
+    private var lastBackgroundEntitlementRefresh: Date?
+    private var appActiveObserver: NSObjectProtocol?
+
+    private init() {
+        migrateLegacyPremiumCacheIfNeeded()
+        hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: Self.cachedHasEverPurchasedKey)
+
+        if UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) {
+            isPremium = true
+            activePremiumPlan = restoredCachedPremiumPlan()
+        }
+    }
+
+    private func migrateLegacyPremiumCacheIfNeeded() {
+        guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return }
+        guard UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey) == nil,
+              let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") else {
+            return
+        }
+
+        let migratedPlan: String?
+        switch legacyKind {
+        case PremiumAccessKind.lifetime.rawValue:
+            migratedPlan = StoreKitCatalog.shared.productID(for: .lifetime)
+        case PremiumAccessKind.subscription.rawValue:
+            migratedPlan = StoreKitCatalog.shared.productID(for: .yearly)
+        default:
+            migratedPlan = nil
+        }
+
+        if let migratedPlan {
+            UserDefaults.standard.set(migratedPlan, forKey: Self.cachedPremiumPlanKey)
+        }
+    }
+
+    private func restoredCachedPremiumPlan() -> PaywallPlan? {
+        guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return nil }
+
+        if let stored = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
+           let plan = PaywallPlan.fromStoredValue(stored) {
+            return plan
+        }
+
+        if let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") {
+            switch legacyKind {
+            case PremiumAccessKind.lifetime.rawValue:
+                return StoreKitCatalog.shared.productID(for: .lifetime).map(PaywallPlan.init(productID:))
+            case PremiumAccessKind.subscription.rawValue:
+                return StoreKitCatalog.shared.productID(for: .yearly).map(PaywallPlan.init(productID:))
+            default:
+                break
+            }
+        }
+
+        return StoreKitCatalog.shared.productID(for: .yearly).map(PaywallPlan.init(productID:))
+    }
+
+    func start() {
+        guard !hasStarted else { return }
+        hasStarted = true
+
+        observeAppLifecycle()
+        transactionListener = Task { [weak self] in
+            await self?.listenForTransactionUpdates()
+        }
+
+        initialEntitlementTask = Task { [weak self] in
+            guard let self else { return }
+            await self.refreshPremiumAccess()
+            await self.loadProducts(presentLoadingUI: false)
+        }
+    }
+
+    private func observeAppLifecycle() {
+        appActiveObserver = NotificationCenter.default.addObserver(
+            forName: NSApplication.didBecomeActiveNotification,
+            object: nil,
+            queue: .main
+        ) { [weak self] _ in
+            Task { @MainActor [weak self] in
+                self?.refreshEntitlementsOnAppActive()
+            }
+        }
+    }
+
+    private func refreshEntitlementsOnAppActive() {
+        guard hasStarted else { return }
+
+        if let lastRefresh = lastBackgroundEntitlementRefresh,
+           Date().timeIntervalSince(lastRefresh) < Self.backgroundRefreshInterval {
+            return
+        }
+
+        entitlementRefreshTask?.cancel()
+        entitlementRefreshTask = Task {
+            await refreshPremiumAccess()
+            lastBackgroundEntitlementRefresh = Date()
+        }
+    }
+
+    func ensureEntitlementsResolved() async {
+        await initialEntitlementTask?.value
+    }
+
+    func ensurePaywallStoreDataLoaded(presentLoadingUI: Bool = true) async {
+        await ensureEntitlementsResolved()
+        await loadProducts(presentLoadingUI: presentLoadingUI && !hasAllProductsLoaded)
+        await refreshTrialOffers()
+    }
+
+    func product(for plan: PaywallPlan) -> Product? {
+        productsByID[plan.productID]
+    }
+
+    func trialDisplay(for plan: PaywallPlan) -> PaywallTrialDisplay? {
+        trialDisplayByPlan[plan]
+    }
+
+    func clearPurchaseError() {
+        purchaseError = nil
+    }
+
+    var isEligibleForIntroTrial: Bool {
+        !hasEverPurchasedPremium && !isPremium
+    }
+
+    func loadProducts(presentLoadingUI: Bool? = nil) async {
+        if let existing = inFlightProductLoad {
+            await existing.value
+            return
+        }
+
+        let uiFlag = presentLoadingUI
+        let task = Task { @MainActor in
+            await performLoadProducts(presentLoadingUI: uiFlag)
+        }
+        inFlightProductLoad = task
+        await task.value
+        inFlightProductLoad = nil
+    }
+
+    @discardableResult
+    func purchase(plan: PaywallPlan) async -> Bool {
+        purchaseError = nil
+
+        var product = productsByID[plan.productID]
+        if product == nil {
+            await loadProducts(presentLoadingUI: false)
+            product = productsByID[plan.productID]
+        }
+
+        guard let product else {
+            purchaseError = products.isEmpty ? .subscriptionUnavailable : .planUnavailable
+            return false
+        }
+
+        purchasingPlan = plan
+        postStoreStateDidChange()
+        defer {
+            purchasingPlan = nil
+            postStoreStateDidChange()
+        }
+
+        do {
+            let result = try await product.purchase()
+            switch result {
+            case .success(let verification):
+                guard case .verified(let transaction) = verification else {
+                    purchaseError = .purchaseNotVerified
+                    return false
+                }
+
+                let trustedPlan = planGrantingPremium(from: transaction)
+                if trustedPlan != nil {
+                    applyPremiumAccess(from: transaction)
+                }
+
+                await transaction.finish()
+                await reconcilePremiumAccess(trustedPlan: trustedPlan)
+                return isPremium
+            case .userCancelled:
+                return false
+            case .pending:
+                purchaseError = .purchasePending
+                return false
+            @unknown default:
+                return false
+            }
+        } catch {
+            purchaseError = .generic
+            return false
+        }
+    }
+
+    @discardableResult
+    func restorePurchases() async -> Bool {
+        purchaseError = nil
+        isRestoringPurchases = true
+        postStoreStateDidChange()
+        defer {
+            isRestoringPurchases = false
+            postStoreStateDidChange()
+        }
+
+        do {
+            try await syncAppStoreWithTimeout(seconds: 30)
+        } catch is StoreSyncError {
+            purchaseError = .restoreSyncTimeout
+            return false
+        } catch {
+            purchaseError = .generic
+            return false
+        }
+
+        await reconcilePremiumAccess(trustedPlan: nil)
+        await syncPurchaseHistory(allowReset: true)
+        if !isPremium {
+            purchaseError = .noActivePurchases
+        }
+        return isPremium
+    }
+
+    func showAlert(title: String, message: String, on window: NSWindow?) {
+        let alert = NSAlert()
+        alert.messageText = title
+        alert.informativeText = message
+        alert.alertStyle = .informational
+        alert.addButton(withTitle: PaywallConfigService.shared.config.messages.alertOK)
+        if let window {
+            alert.beginSheetModal(for: window)
+        } else {
+            alert.runModal()
+        }
+    }
+
+    func showManageSubscriptions() {
+        let urlString = PaywallConfigService.shared.config.urls.manageSubscriptions
+        guard let url = URL(string: urlString) else { return }
+        NSWorkspace.shared.open(url)
+    }
+
+    func showPurchaseError(_ error: PurchaseError, on window: NSWindow?) {
+        let config = PaywallConfigService.shared.config
+        showAlert(
+            title: config.messages.purchaseFailedTitle,
+            message: config.message(for: error),
+            on: window
+        )
+    }
+
+    private func performLoadProducts(presentLoadingUI: Bool?) async {
+        let shouldReload = presentLoadingUI == true || productsByID.isEmpty
+        let shouldShowLoading = presentLoadingUI ?? productsByID.isEmpty
+
+        if !shouldReload, presentLoadingUI != true {
+            purchaseError = nil
+            await refreshPremiumAccess()
+            await refreshTrialOffers()
+            postStoreStateDidChange()
+            return
+        }
+
+        if shouldShowLoading {
+            isLoadingProducts = true
+            postStoreStateDidChange()
+        }
+        defer {
+            if shouldShowLoading {
+                isLoadingProducts = false
+                postStoreStateDidChange()
+            }
+        }
+
+        purchaseError = nil
+        productLoadError = nil
+        do {
+            let loaded = try await loadProductsWithTimeout(seconds: 12)
+            guard !loaded.isEmpty else {
+                productLoadError = PaywallConfigService.shared.config.messages.noPlansAvailable
+                productsByID = [:]
+                await refreshTrialOffers()
+                return
+            }
+            var map: [String: Product] = [:]
+            for product in loaded {
+                map[product.id] = product
+            }
+            productsByID = map
+            productLoadError = nil
+            NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
+            await refreshPremiumAccess()
+            await refreshTrialOffers()
+        } catch is ProductLoadError {
+            productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
+            purchaseError = .productsLoadTimeout
+            await refreshTrialOffers()
+        } catch {
+            productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
+            purchaseError = .generic
+            await refreshTrialOffers()
+        }
+    }
+
+    private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] {
+        let productIDs = productCatalog.allProductIDs
+        return try await withThrowingTaskGroup(of: [Product].self) { group in
+            group.addTask {
+                try await Product.products(for: productIDs)
+            }
+            group.addTask {
+                let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
+                try await Task.sleep(nanoseconds: timeoutNanoseconds)
+                throw ProductLoadError.timeout
+            }
+
+            guard let first = try await group.next() else {
+                throw ProductLoadError.timeout
+            }
+            group.cancelAll()
+            return first
+        }
+    }
+
+    private func refreshPremiumAccess(trustedPlan: PaywallPlan? = nil) async {
+        var resolvedPlan: PaywallPlan?
+
+        if let lifetimePlan = await activeLifetimePlan() {
+            resolvedPlan = lifetimePlan
+        }
+
+        if resolvedPlan == nil {
+            for await result in Transaction.currentEntitlements {
+                guard case .verified(let transaction) = result else { continue }
+                guard let plan = planGrantingPremium(from: transaction) else { continue }
+                resolvedPlan = preferredPlan(resolvedPlan, plan)
+            }
+        }
+
+        if resolvedPlan == nil || resolvedPlan?.isLifetime != true {
+            if let subscriptionPlan = await activeSubscriptionPlan() {
+                resolvedPlan = preferredPlan(resolvedPlan, subscriptionPlan)
+            }
+        }
+
+        if resolvedPlan == nil {
+            if let statusPlan = await activePlanFromSubscriptionStatus() {
+                resolvedPlan = statusPlan
+            }
+        }
+
+        let finalPlan = mergeTrustedPlan(resolvedPlan, trustedPlan)
+
+        applyPremiumStatus(plan: finalPlan)
+        hasResolvedPremiumStatus = true
+        NotificationCenter.default.post(name: .entitlementsDidResolve, object: nil)
+        await syncPurchaseHistory(allowReset: false)
+        await refreshTrialOffers()
+    }
+
+    private func reconcilePremiumAccess(trustedPlan: PaywallPlan?) async {
+        await refreshPremiumAccess(trustedPlan: trustedPlan)
+
+        guard let trustedPlan, !isPremium else { return }
+
+        do {
+            try await syncAppStoreWithTimeout(seconds: 15)
+        } catch {
+            applyTrustedPlan(trustedPlan)
+            return
+        }
+
+        await refreshPremiumAccess(trustedPlan: trustedPlan)
+        if !isPremium {
+            applyTrustedPlan(trustedPlan)
+        }
+    }
+
+    private func mergeTrustedPlan(_ resolvedPlan: PaywallPlan?, _ trustedPlan: PaywallPlan?) -> PaywallPlan? {
+        switch (resolvedPlan, trustedPlan) {
+        case (nil, nil):
+            nil
+        case (let resolved?, nil):
+            resolved
+        case (nil, let trusted?):
+            trusted
+        case (let resolved?, let trusted?):
+            preferredPlan(resolved, trusted)
+        }
+    }
+
+    private func applyTrustedPlan(_ plan: PaywallPlan) {
+        activePremiumPlan = preferredPlan(activePremiumPlan, plan)
+        applyPremiumStatus(plan: activePremiumPlan)
+        hasResolvedPremiumStatus = true
+        markAsHavingPurchasedPremium()
+    }
+
+    private func revokePremiumAccess() {
+        applyPremiumStatus(plan: nil)
+        hasResolvedPremiumStatus = true
+        trialDisplayByPlan = [:]
+    }
+
+    private func syncAppStoreWithTimeout(seconds: TimeInterval) async throws {
+        try await withThrowingTaskGroup(of: Void.self) { group in
+            group.addTask {
+                try await AppStore.sync()
+            }
+            group.addTask {
+                let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
+                try await Task.sleep(nanoseconds: timeoutNanoseconds)
+                throw StoreSyncError.timeout
+            }
+
+            try await group.next()
+            group.cancelAll()
+        }
+    }
+
+    private func applyPremiumAccess(from transaction: StoreKit.Transaction) {
+        guard let plan = planGrantingPremium(from: transaction) else { return }
+        activePremiumPlan = preferredPlan(activePremiumPlan, plan)
+        applyPremiumStatus(plan: activePremiumPlan)
+        hasResolvedPremiumStatus = true
+        markAsHavingPurchasedPremium()
+    }
+
+    private func markAsHavingPurchasedPremium() {
+        guard !hasEverPurchasedPremium else { return }
+        hasEverPurchasedPremium = true
+        trialDisplayByPlan = [:]
+        UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
+    }
+
+    private func syncPurchaseHistory(allowReset: Bool) async {
+        var hasRecordedPurchase = false
+        let knownProductIDs = productCatalog.allProductIDs
+
+        for await result in Transaction.all {
+            guard case .verified(let transaction) = result else { continue }
+            guard knownProductIDs.contains(transaction.productID) else { continue }
+            hasRecordedPurchase = true
+            break
+        }
+
+        if hasRecordedPurchase {
+            if !hasEverPurchasedPremium {
+                hasEverPurchasedPremium = true
+                UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
+            }
+        } else if allowReset, hasEverPurchasedPremium {
+            resetToNeverPurchasedState()
+        }
+    }
+
+    private func resetToNeverPurchasedState() {
+        hasEverPurchasedPremium = false
+        UserDefaults.standard.set(false, forKey: Self.cachedHasEverPurchasedKey)
+        trialDisplayByPlan = [:]
+    }
+
+    private func refreshTrialOffers() async {
+        guard !isPremium else {
+            trialDisplayByPlan = [:]
+            postStoreStateDidChange()
+            return
+        }
+
+        guard let eligiblePlan = PaywallConfigService.shared.config.trialEligiblePlan else {
+            trialDisplayByPlan = [:]
+            postStoreStateDidChange()
+            return
+        }
+
+        if let display = await resolveTrialDisplay(for: eligiblePlan) {
+            trialDisplayByPlan = [eligiblePlan: display]
+        } else {
+            trialDisplayByPlan = [:]
+        }
+        postStoreStateDidChange()
+    }
+
+    private func resolveTrialDisplay(for plan: PaywallPlan) async -> PaywallTrialDisplay? {
+        guard plan == PaywallConfigService.shared.config.trialEligiblePlan else { return nil }
+        guard !isPremium else { return nil }
+
+        let config = PaywallConfigService.shared.config
+
+        guard let product = product(for: plan),
+              let subscription = product.subscription else {
+            return fallbackTrialDisplay(config: config)
+        }
+
+        guard await subscription.isEligibleForIntroOffer else { return nil }
+
+        if let offer = subscription.introductoryOffer {
+            return PaywallTrialDisplay.from(offer: offer, config: config)
+        }
+
+        return fallbackTrialDisplay(config: config)
+    }
+
+    private func fallbackTrialDisplay(config: PaywallConfig) -> PaywallTrialDisplay? {
+        guard !isPremium else { return nil }
+        guard let fallback = config.trial.fallbackDuration else { return nil }
+        return PaywallTrialDisplay.from(fallback: fallback, config: config)
+    }
+
+    private func applyPremiumStatus(plan: PaywallPlan?) {
+        let hasPremium = plan != nil
+        let kind: PremiumAccessKind
+        switch plan?.kind {
+        case .lifetime: kind = .lifetime
+        case .monthly, .yearly: kind = .subscription
+        case nil: kind = .none
+        }
+
+        let previousKind = premiumAccessKind
+        let previousPlan = activePremiumPlan
+        let didChange = hasPremium != isPremium || kind != previousKind || plan != previousPlan
+
+        isPremium = hasPremium
+        activePremiumPlan = plan
+        UserDefaults.standard.set(hasPremium, forKey: Self.cachedPremiumKey)
+        if let plan {
+            UserDefaults.standard.set(plan.productID, forKey: Self.cachedPremiumPlanKey)
+        } else {
+            UserDefaults.standard.removeObject(forKey: Self.cachedPremiumPlanKey)
+        }
+
+        if didChange {
+            NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
+        }
+        postStoreStateDidChange()
+    }
+
+    private func planGrantingPremium(from transaction: StoreKit.Transaction) -> PaywallPlan? {
+        guard transactionGrantsPremium(transaction) else { return nil }
+        return productCatalog.plan(for: transaction.productID)
+    }
+
+    private func transactionGrantsPremium(_ transaction: StoreKit.Transaction) -> Bool {
+        guard productCatalog.allProductIDs.contains(transaction.productID) else { return false }
+        guard transaction.revocationDate == nil else { return false }
+        if let expirationDate = transaction.expirationDate, expirationDate < Date() {
+            return false
+        }
+        return true
+    }
+
+    private func preferredPlan(_ current: PaywallPlan?, _ candidate: PaywallPlan) -> PaywallPlan {
+        StoreKitCatalog.shared.preferredPlan(current, candidate)
+    }
+
+    private func activeLifetimePlan() async -> PaywallPlan? {
+        guard let lifetimeProductID = StoreKitCatalog.shared.productID(for: .lifetime) else {
+            return nil
+        }
+        guard let result = await Transaction.latest(for: lifetimeProductID) else {
+            return nil
+        }
+        guard case .verified(let transaction) = result else { return nil }
+        return planGrantingPremium(from: transaction)
+    }
+
+    private func activeSubscriptionPlan() async -> PaywallPlan? {
+        var resolvedPlan: PaywallPlan?
+
+        for await result in Transaction.currentEntitlements {
+            guard case .verified(let transaction) = result else { continue }
+            guard let plan = planGrantingPremium(from: transaction), !plan.isLifetime else { continue }
+            resolvedPlan = preferredPlan(resolvedPlan, plan)
+        }
+
+        return resolvedPlan
+    }
+
+    private func activePlanFromSubscriptionStatus() async -> PaywallPlan? {
+        for plan in StoreKitCatalog.shared.subscriptionPlans {
+            let productID = plan.productID
+            let product: Product?
+            if let cached = productsByID[productID] {
+                product = cached
+            } else {
+                product = try? await Product.products(for: [productID]).first
+            }
+
+            guard let product,
+                  let subscription = product.subscription,
+                  let statuses = try? await subscription.status else {
+                continue
+            }
+
+            for status in statuses {
+                switch status.state {
+                case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
+                    return plan
+                default:
+                    continue
+                }
+            }
+        }
+        return nil
+    }
+
+    private func listenForTransactionUpdates() async {
+        for await update in Transaction.updates {
+            guard case .verified(let transaction) = update else { continue }
+
+            if isRevokedAppTransaction(transaction) {
+                revokePremiumAccess()
+                await transaction.finish()
+                await refreshPremiumAccess()
+                continue
+            }
+
+            let trustedPlan = planGrantingPremium(from: transaction)
+            if trustedPlan != nil {
+                applyPremiumAccess(from: transaction)
+            }
+
+            await transaction.finish()
+
+            if trustedPlan != nil {
+                await reconcilePremiumAccess(trustedPlan: trustedPlan)
+            } else {
+                await refreshPremiumAccess()
+            }
+        }
+    }
+
+    private func isRevokedAppTransaction(_ transaction: StoreKit.Transaction) -> Bool {
+        productCatalog.allProductIDs.contains(transaction.productID) && transaction.revocationDate != nil
+    }
+
+    private func postStoreStateDidChange() {
+        NotificationCenter.default.post(name: .storeStateDidChange, object: nil)
+    }
+}
+
+extension Notification.Name {
+    static let premiumStatusDidChange = Notification.Name("premiumStatusDidChange")
+    static let entitlementsDidResolve = Notification.Name("entitlementsDidResolve")
+    static let storeProductsDidUpdate = Notification.Name("storeProductsDidUpdate")
+    static let storeStateDidChange = Notification.Name("storeStateDidChange")
+}

+ 252 - 11
smart_printer/PaywallConfig.swift

@@ -1,8 +1,10 @@
 import Foundation
+import OSLog
 import StoreKit
 
 struct PaywallConfig: Codable, Sendable {
     struct PlanCopy: Codable, Sendable {
+        let productID: String
         let title: String
         let badge: String?
         let subtitleTemplate: String
@@ -14,6 +16,7 @@ struct PaywallConfig: Codable, Sendable {
         let features: [String]
 
         init(
+            productID: String,
             title: String,
             badge: String? = nil,
             subtitleTemplate: String,
@@ -24,6 +27,7 @@ struct PaywallConfig: Codable, Sendable {
             fallbackBillingDescription: String = "",
             features: [String] = []
         ) {
+            self.productID = productID
             self.title = title
             self.badge = badge
             self.subtitleTemplate = subtitleTemplate
@@ -155,7 +159,12 @@ struct PaywallConfig: Codable, Sendable {
 }
 
 extension PaywallConfig {
-    /// Applies remote marketing copy only. Legal URLs and subscription disclosures stay bundled.
+    private static let logger = Logger(
+        subsystem: Bundle.main.bundleIdentifier ?? "com.printer-app-all-printers",
+        category: "PaywallConfig"
+    )
+
+    /// Applies remote marketing copy only. Product IDs, legal URLs, and disclosures stay bundled.
     func mergingMarketing(from remote: PaywallConfig) -> PaywallConfig {
         PaywallConfig(
             leftPanelTitle: remote.leftPanelTitle,
@@ -164,7 +173,7 @@ extension PaywallConfig {
             features: remote.features,
             trustItems: remote.trustItems,
             lifetimeTrustItem: remote.lifetimeTrustItem,
-            plans: remote.plans,
+            plans: plans.mergingMarketing(from: remote.plans),
             footer: footer,
             urls: urls,
             trial: trial,
@@ -207,12 +216,147 @@ extension PaywallConfig {
     }
 
     static func loadBundled() -> PaywallConfig {
-        guard let url = Bundle.main.url(forResource: "paywall", withExtension: "json"),
-              let data = try? Data(contentsOf: url),
-              let config = try? JSONDecoder().decode(PaywallConfig.self, from: data) else {
-            fatalError("Missing or invalid paywall.json in app bundle.")
+        guard let url = Bundle.main.url(forResource: "paywall", withExtension: "json") else {
+            logger.error("paywall.json is missing from app bundle. Falling back to built-in defaults.")
+            return fallback
+        }
+
+        do {
+            let data = try Data(contentsOf: url)
+            return try JSONDecoder().decode(PaywallConfig.self, from: data)
+        } catch {
+            logger.error("Failed to decode bundled paywall.json: \(error.localizedDescription, privacy: .public). Falling back to built-in defaults.")
+            return fallback
         }
-        return config
+    }
+
+    static var fallback: PaywallConfig {
+        PaywallConfig(
+            leftPanelTitle: "Unlock Premium Features",
+            rightTitle: "Go Premium",
+            rightSubtitle: "Choose a plan to unlock all premium printing tools.",
+            features: [
+                "Unlimited high-quality scans",
+                "Advanced OCR technology",
+                "Draw & print canvas",
+                "Priority support",
+            ],
+            trustItems: [
+                TrustItem(icon: "shield.fill", title: "Secure Payments", subtitle: "Your payment is protected."),
+                TrustItem(icon: "arrow.counterclockwise", title: "Cancel Anytime", subtitle: "Manage subscriptions anytime."),
+                TrustItem(icon: "headphones", title: "24/7 Support", subtitle: "We're here to help."),
+                TrustItem(icon: "lock.fill", title: "Privacy First", subtitle: "Your data stays private."),
+            ],
+            lifetimeTrustItem: TrustItem(
+                icon: "checkmark.seal.fill",
+                title: "One-Time Purchase",
+                subtitle: "Pay once, no recurring charges."
+            ),
+            plans: Plans(
+                monthly: PlanCopy(
+                    productID: "com.printer_app_all_printers.premium.monthly",
+                    title: "Monthly",
+                    subtitleTemplate: "{price} / month, cancel anytime",
+                    ctaTemplate: "Subscribe for {price} / Month",
+                    fallbackPrice: "—",
+                    priceSuffix: "/mo",
+                    fallbackBillingDescription: "Billed at {price} every month",
+                    features: ["Unlimited scans", "Advanced OCR", "Draw & print canvas"]
+                ),
+                yearly: PlanCopy(
+                    productID: "com.printer_app_all_printers.premium.yearly",
+                    title: "Yearly",
+                    subtitleTemplate: "{price} / year, cancel anytime",
+                    trialSubtitleTemplate: "{trial} free, then {price} / year",
+                    ctaTemplate: "Subscribe for {price} / Year",
+                    fallbackPrice: "—",
+                    priceSuffix: "/yr",
+                    fallbackBillingDescription: "Billed at {price} every year",
+                    features: ["Unlimited scans", "Advanced OCR", "Draw & print canvas"]
+                ),
+                lifetime: PlanCopy(
+                    productID: "com.printer_app_all_printers.premium.lifetime",
+                    title: "Lifetime",
+                    subtitleTemplate: "{price} once, lifetime access",
+                    ctaTemplate: "Buy Lifetime Access for {price}",
+                    fallbackPrice: "—",
+                    priceSuffix: nil,
+                    fallbackBillingDescription: "One-time payment of {price}",
+                    features: ["Unlimited scans", "Advanced OCR", "Draw & print canvas"]
+                )
+            ),
+            footer: Footer(
+                continueFree: "Continue with free plan",
+                manageSubscription: "Manage Subscription",
+                restorePurchase: "Restore Purchase",
+                privacyPolicy: "Privacy Policy",
+                termsOfService: "Terms of Service",
+                support: "Support"
+            ),
+            urls: URLs(
+                privacy: "https://sites.google.com/view/smartprinterappmacos/privacy-policy",
+                terms: "https://sites.google.com/view/smartprinterappmacos/terms-and-condition",
+                support: "https://sites.google.com/view/smartprinterappmacos/get-support",
+                manageSubscriptions: "https://apps.apple.com/account/subscriptions"
+            ),
+            trial: Trial(
+                eligiblePlan: "yearly",
+                badgeTemplate: "{duration} Free Trial",
+                ctaTemplate: "Start {duration} Free Trial",
+                fallbackDuration: TrialFallbackDuration(count: 3, unit: "day")
+            ),
+            duration: Duration(
+                units: DurationUnits(
+                    day: DurationUnit(one: "Day", other: "Days", lowerOne: "day", lowerOther: "days"),
+                    week: DurationUnit(one: "Week", other: "Weeks", lowerOne: "week", lowerOther: "weeks"),
+                    month: DurationUnit(one: "Month", other: "Months", lowerOne: "month", lowerOther: "months"),
+                    year: DurationUnit(one: "Year", other: "Years", lowerOne: "year", lowerOther: "years")
+                ),
+                countUnitTemplate: "{count} {unit}",
+                unknownFallback: "{count} days"
+            ),
+            messages: Messages(
+                productNotFound: "The selected plan is not available right now. Please try again later.",
+                failedVerification: "We couldn't verify your purchase. Please contact support.",
+                noPlansAvailable: "No subscription plans are available right now.",
+                plansLoadFailed: "Couldn't load subscription plans. Check your connection and try again.",
+                purchaseFailedTitle: "Purchase Failed",
+                restoreNotFoundTitle: "No Purchases Found",
+                restoreNotFoundMessage: "We couldn't find any previous purchases for this Apple ID.",
+                restoreSuccessTitle: "Purchases Restored",
+                restoreSuccessMessage: "Your premium access has been restored.",
+                pendingPurchaseTitle: "Purchase Pending",
+                pendingPurchaseMessage: "Your purchase is waiting for approval. You'll get access once it's approved.",
+                alertOK: "OK",
+                retry: "Retry",
+                retrying: "Retrying…",
+                periodFallback: "period",
+                productsLoadTimeout: "Couldn't load subscription products right now. Check your connection and try again.",
+                subscriptionUnavailable: "This subscription is currently unavailable. Please try again in a moment.",
+                planUnavailable: "This plan isn't available right now. Please try again later.",
+                noActiveSubscriptions: "No active subscriptions were found for this Apple ID.",
+                restoreSyncTimeout: "Couldn't reach the App Store in time. Check your connection and tap Restore Purchases again.",
+                purchaseGeneric: "Something went wrong with your subscription. Please try again."
+            ),
+            sidebar: Sidebar(
+                unlockTitle: "Unlock Premium",
+                proTitle: "Printer App - All Printers Pro",
+                unlockDescription: "Upgrade to Premium for unlimited scans, printing, OCR, and all features.",
+                proDescription: "You have unlimited scans, printing, OCR and all premium features.",
+                upgradeAction: "Upgrade Now",
+                manageSubscriptionAction: "Manage Subscription",
+                lifetimeAction: "Premium Active"
+            ),
+            loadingPrice: "—",
+            loadingCTA: "Loading plans…",
+            subscriptionDisclosureTemplate: "Payment will be charged to your Apple ID. Subscription automatically renews at {price} per {period} unless canceled at least 24 hours before the end of the current period.",
+            trialDisclosureTemplate: "After your {trial} free trial, payment will be charged to your Apple ID. Subscription automatically renews at {price} per {period} unless canceled at least 24 hours before the end of the current period.",
+            lifetimeDisclosure: "One-time purchase. No subscription or recurring charges.",
+            loadingDisclosure: "Payment will be charged to your Apple ID. Subscription automatically renews unless canceled at least 24 hours before the end of the current period.",
+            securePaymentNote: "Secure payment. Cancel anytime.",
+            lifetimeSecurePaymentNote: "Secure payment. One-time purchase.",
+            closeButtonHelp: "Close"
+        )
     }
 
     func formatDuration(count: Int, unit: Product.SubscriptionPeriod.Unit, lowercase: Bool) -> String {
@@ -276,10 +420,6 @@ extension PaywallConfig {
         return formatDuration(count: fallback.count, unit: unit, lowercase: lowercase)
     }
 
-    var trialEligiblePlan: PaywallPlan? {
-        PaywallPlan(rawValue: trial.eligiblePlan)
-    }
-
     private func subscriptionPeriodUnit(from value: String) -> Product.SubscriptionPeriod.Unit? {
         switch value {
         case "day": .day
@@ -289,6 +429,107 @@ extension PaywallConfig {
         default: nil
         }
     }
+
+    var trialEligiblePlan: PaywallPlan? {
+        StoreKitCatalog.shared.plan(matching: trial.eligiblePlan)
+    }
+
+    var allProductIDs: Set<String> {
+        StoreKitCatalog.allProductIDs
+    }
+
+    func plan(for productID: String) -> PaywallPlan? {
+        guard StoreKitCatalog.shared.contains(productID: productID) else { return nil }
+        return PaywallPlan(productID: productID)
+    }
+
+    func planCopy(for productID: String) -> PlanCopy? {
+        if productID == plans.monthly.productID { return plans.monthly }
+        if productID == plans.yearly.productID { return plans.yearly }
+        if productID == plans.lifetime.productID { return plans.lifetime }
+        return nil
+    }
+
+    func synthesizedPlanCopy(for product: Product) -> PlanCopy {
+        let title = product.displayName
+
+        if product.type == .nonConsumable {
+            return PlanCopy(
+                productID: product.id,
+                title: title,
+                subtitleTemplate: plans.lifetime.subtitleTemplate,
+                ctaTemplate: plans.lifetime.ctaTemplate,
+                fallbackPrice: loadingPrice,
+                priceSuffix: plans.lifetime.priceSuffix,
+                fallbackBillingDescription: plans.lifetime.fallbackBillingDescription,
+                features: plans.lifetime.features
+            )
+        }
+
+        guard let subscription = product.subscription else {
+            return placeholderPlanCopy(for: product.id)
+        }
+
+        let periodLower = subscriptionPeriodDescription(from: subscription.subscriptionPeriod)
+        let periodTitle = formatDuration(
+            count: subscription.subscriptionPeriod.value,
+            unit: subscription.subscriptionPeriod.unit,
+            lowercase: false
+        )
+
+        return PlanCopy(
+            productID: product.id,
+            title: title,
+            subtitleTemplate: "{price} / \(periodLower), cancel anytime",
+            trialSubtitleTemplate: "{trial} free, then {price} / \(periodLower)",
+            ctaTemplate: "Subscribe for {price} / \(periodTitle)",
+            fallbackPrice: loadingPrice,
+            priceSuffix: "/\(periodLower)",
+            fallbackBillingDescription: "Billed at {price} every \(periodLower)",
+            features: plans.monthly.features
+        )
+    }
+
+    func placeholderPlanCopy(for productID: String) -> PlanCopy {
+        let title = StoreKitCatalog.shared.entry(for: productID)?.displayName ?? "Premium"
+        return PlanCopy(
+            productID: productID,
+            title: title,
+            subtitleTemplate: "{price}, cancel anytime",
+            ctaTemplate: "Subscribe for {price}",
+            fallbackPrice: loadingPrice,
+            priceSuffix: nil,
+            fallbackBillingDescription: "Billed at {price}",
+            features: plans.monthly.features
+        )
+    }
+}
+
+extension PaywallConfig.Plans {
+    func mergingMarketing(from remote: PaywallConfig.Plans) -> PaywallConfig.Plans {
+        PaywallConfig.Plans(
+            monthly: monthly.mergingMarketing(from: remote.monthly),
+            yearly: yearly.mergingMarketing(from: remote.yearly),
+            lifetime: lifetime.mergingMarketing(from: remote.lifetime)
+        )
+    }
+}
+
+extension PaywallConfig.PlanCopy {
+    func mergingMarketing(from remote: PaywallConfig.PlanCopy) -> PaywallConfig.PlanCopy {
+        PaywallConfig.PlanCopy(
+            productID: productID,
+            title: remote.title,
+            badge: remote.badge,
+            subtitleTemplate: remote.subtitleTemplate,
+            trialSubtitleTemplate: remote.trialSubtitleTemplate,
+            ctaTemplate: remote.ctaTemplate,
+            fallbackPrice: remote.fallbackPrice,
+            priceSuffix: remote.priceSuffix,
+            fallbackBillingDescription: remote.fallbackBillingDescription,
+            features: remote.features
+        )
+    }
 }
 
 extension Notification.Name {

+ 0 - 32
smart_printer/PaywallConfigService.swift

@@ -4,10 +4,6 @@ import Foundation
 final class PaywallConfigService {
     static let shared = PaywallConfigService()
 
-    private static let remoteConfigURL = URL(
-        string: "https://git.mqldevelopment.com/ahtasham.shahzad/smart_printer/-/raw/main/smart_printer/paywall.json"
-    )!
-
     private(set) var config: PaywallConfig
     private var hasStarted = false
 
@@ -18,34 +14,6 @@ final class PaywallConfigService {
     func start() {
         guard !hasStarted else { return }
         hasStarted = true
-        #if DEBUG
-        Task { await refreshFromRemote() }
-        #endif
-    }
-
-    func refreshFromRemote() async {
-        var request = URLRequest(url: Self.remoteConfigURL)
-        request.cachePolicy = .reloadIgnoringLocalCacheData
-        request.timeoutInterval = 15
-
-        do {
-            let (data, response) = try await URLSession.shared.data(for: request)
-            guard let http = response as? HTTPURLResponse, (200 ... 299).contains(http.statusCode) else {
-                return
-            }
-            let remoteConfig = try JSONDecoder().decode(PaywallConfig.self, from: data)
-            apply(remoteConfig)
-        } catch {
-            NSLog("Failed to refresh paywall config: \(error.localizedDescription)")
-        }
-    }
-
-    private func apply(_ remoteConfig: PaywallConfig) {
-        let bundledConfig = PaywallConfig.loadBundled()
-        let mergedConfig = bundledConfig.mergingMarketing(from: remoteConfig)
-        guard mergedConfig != config else { return }
-        config = mergedConfig
-        NotificationCenter.default.post(name: .paywallConfigDidUpdate, object: nil)
     }
 }
 

+ 100 - 0
smart_printer/PaywallModels.swift

@@ -1,6 +1,106 @@
 import Foundation
 import StoreKit
 
+struct PaywallPlan: Hashable, Sendable {
+    let productID: String
+
+    init(productID: String) {
+        self.productID = productID
+    }
+
+    static var displayOrder: [PaywallPlan] {
+        StoreKitCatalog.orderedPlans
+    }
+
+    var kind: PaywallPlanKind? {
+        StoreKitCatalog.shared.kind(for: productID)
+    }
+
+    var isLifetime: Bool {
+        kind == .lifetime
+    }
+
+    var isSubscription: Bool {
+        kind != .lifetime && kind != nil
+    }
+
+    static func fromStoredValue(_ value: String) -> PaywallPlan? {
+        StoreKitCatalog.shared.plan(matching: value)
+    }
+
+    func planCopy(from config: PaywallConfig, product: Product? = nil) -> PaywallConfig.PlanCopy {
+        if let copy = config.planCopy(for: productID) {
+            return copy
+        }
+        if let product {
+            return config.synthesizedPlanCopy(for: product)
+        }
+        return config.placeholderPlanCopy(for: productID)
+    }
+
+    func localizedPrice(from product: Product?, config: PaywallConfig) -> String {
+        product?.displayPrice ?? config.loadingPrice
+    }
+
+    func localizedSubtitle(
+        from product: Product?,
+        config: PaywallConfig,
+        trial: PaywallTrialDisplay? = nil
+    ) -> String {
+        guard let product else { return config.loadingPrice }
+        let copy = planCopy(from: config, product: product)
+        let price = product.displayPrice
+
+        if let trial, let trialTemplate = copy.trialSubtitleTemplate {
+            return trialTemplate
+                .replacingOccurrences(of: "{trial}", with: trial.durationLowercased)
+                .replacingOccurrences(of: "{price}", with: price)
+        }
+
+        return copy.subtitleTemplate.replacingOccurrences(of: "{price}", with: price)
+    }
+
+    func localizedCTATitle(
+        from product: Product?,
+        config: PaywallConfig,
+        trial: PaywallTrialDisplay? = nil
+    ) -> String {
+        guard let product else { return config.loadingCTA }
+        if let trial {
+            return trial.ctaText
+        }
+        let copy = planCopy(from: config, product: product)
+        return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: product.displayPrice)
+    }
+
+    func localizedRenewalDisclosure(
+        from product: Product?,
+        config: PaywallConfig,
+        trial: PaywallTrialDisplay? = nil
+    ) -> String {
+        if isLifetime {
+            return config.lifetimeDisclosure
+        }
+
+        guard let product else { return config.loadingDisclosure }
+        let price = product.displayPrice
+        let period = product.subscription.map {
+            config.subscriptionPeriodDescription(from: $0.subscriptionPeriod)
+        } ?? config.messages.periodFallback
+
+        if let trial {
+            return config.trialDisclosureTemplate
+                .replacingOccurrences(of: "{trial}", with: trial.durationLowercased)
+                .replacingOccurrences(of: "{price}", with: price)
+                .replacingOccurrences(of: "{period}", with: period)
+        }
+
+        return config.subscriptionDisclosureTemplate
+            .replacingOccurrences(of: "{price}", with: price)
+            .replacingOccurrences(of: "{period}", with: period)
+    }
+}
+
 struct PaywallTrialDisplay: Equatable, Sendable {
     let badgeText: String
     let durationLowercased: String

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 950
smart_printer/PaywallView.swift


+ 2 - 0
smart_printer/ScanFileView.swift

@@ -822,6 +822,7 @@ private final class ScanPageThumbnailView: NSControl, AppearanceRefreshable {
 // MARK: - Editable Scan Content
 
 /// Shows the imported scan image above editable OCR text.
+@MainActor
 final class EditableScanContentView: NSView, AppearanceRefreshable {
     var onRecognitionComplete: (() -> Void)?
 
@@ -892,6 +893,7 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
         thumbnailViews.forEach { $0.refreshAppearance() }
     }
 
+    @MainActor
     func loadDocument(pages: [ScanPage]) {
         if pages.allSatisfy({ !$0.text.isEmpty }) {
             let parsed = pages.map(ScanDocumentBuilder.parsedPage(from:))

+ 14 - 10
smart_printer/ScannerOverlayView.swift

@@ -3,6 +3,7 @@ import ImageCaptureCore
 
 // MARK: - Scanner View
 
+@MainActor
 final class ScannerOverlayView: NSView, AppearanceRefreshable {
     var onScanComplete: ((NSImage) -> Void)?
     var onDismiss: (() -> Void)?
@@ -42,8 +43,8 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
     private let savePDFButton = ScannerSecondaryButton(title: "Save as PDF", symbolName: "square.and.arrow.down")
     private let printButton = ScannerSecondaryButton(title: "Print", symbolName: "printer.fill")
 
-    init(scanner: ScannerService = .shared) {
-        self.scanner = scanner
+    init(scanner: ScannerService? = nil) {
+        self.scanner = scanner ?? .shared
         super.init(frame: .zero)
         translatesAutoresizingMaskIntoConstraints = false
         setup()
@@ -51,7 +52,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         refreshAppearance()
         rebuildDeviceLists()
         updateSelectionUI()
-        updateStateUI(scanner.state)
+        updateStateUI(self.scanner.state)
         NotificationCenter.default.addObserver(
             self,
             selector: #selector(appearanceDidChange),
@@ -111,19 +112,22 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
     }
 
     func dismiss(animated: Bool = true) {
-        let remove = { [weak self] in
-            self?.teardown()
-            self?.removeFromSuperview()
-            self?.onDismiss?()
-        }
         guard animated else {
-            remove()
+            teardown()
+            removeFromSuperview()
+            onDismiss?()
             return
         }
         NSAnimationContext.runAnimationGroup({ context in
             context.duration = 0.15
             animator().alphaValue = 0
-        }, completionHandler: remove)
+        }, completionHandler: { [weak self] in
+            MainActor.assumeIsolated {
+                self?.teardown()
+                self?.removeFromSuperview()
+                self?.onDismiss?()
+            }
+        })
     }
 
     func refreshAppearance() {

+ 117 - 0
smart_printer/Services/StoreKitCatalog.swift

@@ -0,0 +1,117 @@
+import Foundation
+import StoreKit
+
+enum PaywallPlanKind: String, CaseIterable, Sendable {
+    case monthly
+    case yearly
+    case lifetime
+}
+
+struct StoreKitCatalogEntry: Sendable {
+    let productID: String
+    let displayName: String?
+    let kind: PaywallPlanKind
+    let sortOrder: Int
+}
+
+enum StoreKitCatalog {
+    static let shared: StoreKitCatalogSnapshot = StoreKitCatalogSnapshot()
+
+    static var orderedPlans: [PaywallPlan] { shared.orderedPlans }
+    static var allProductIDs: Set<String> { shared.allProductIDs }
+}
+
+struct StoreKitCatalogSnapshot: Sendable {
+    let entries: [StoreKitCatalogEntry]
+    let orderedPlans: [PaywallPlan]
+    let allProductIDs: Set<String>
+    private let entriesByProductID: [String: StoreKitCatalogEntry]
+
+    init() {
+        let configuredEntries = Self.entriesFromPaywallConfig()
+        entries = configuredEntries
+        orderedPlans = configuredEntries.map { PaywallPlan(productID: $0.productID) }
+        allProductIDs = Set(configuredEntries.map(\.productID))
+        entriesByProductID = Dictionary(uniqueKeysWithValues: configuredEntries.map { ($0.productID, $0) })
+    }
+
+    func contains(productID: String) -> Bool {
+        allProductIDs.contains(productID)
+    }
+
+    func entry(for productID: String) -> StoreKitCatalogEntry? {
+        entriesByProductID[productID]
+    }
+
+    func kind(for productID: String) -> PaywallPlanKind? {
+        entry(for: productID)?.kind
+    }
+
+    func productID(for kind: PaywallPlanKind) -> String? {
+        entries.first(where: { $0.kind == kind })?.productID
+    }
+
+    func plan(matching storedValue: String) -> PaywallPlan? {
+        if contains(productID: storedValue) {
+            return PaywallPlan(productID: storedValue)
+        }
+        if let kind = PaywallPlanKind(rawValue: storedValue),
+           let productID = productID(for: kind) {
+            return PaywallPlan(productID: productID)
+        }
+        return nil
+    }
+
+    func defaultSelectedPlan() -> PaywallPlan {
+        if let yearlyID = productID(for: .yearly) {
+            return PaywallPlan(productID: yearlyID)
+        }
+        if let firstSubscription = entries.first(where: { $0.kind != .lifetime }) {
+            return PaywallPlan(productID: firstSubscription.productID)
+        }
+        if let first = entries.first {
+            return PaywallPlan(productID: first.productID)
+        }
+        let bundled = PaywallConfig.loadBundled()
+        return PaywallPlan(productID: bundled.plans.yearly.productID)
+    }
+
+    func preferredPlan(_ current: PaywallPlan?, _ candidate: PaywallPlan) -> PaywallPlan {
+        guard let current else { return candidate }
+        let currentTier = tier(for: current.productID)
+        let candidateTier = tier(for: candidate.productID)
+        return candidateTier >= currentTier ? candidate : current
+    }
+
+    func tier(for productID: String) -> Int {
+        entry(for: productID)?.sortOrder ?? 0
+    }
+
+    var subscriptionPlans: [PaywallPlan] {
+        orderedPlans.filter { kind(for: $0.productID) != .lifetime }
+    }
+
+    private static func entriesFromPaywallConfig() -> [StoreKitCatalogEntry] {
+        let config = PaywallConfig.loadBundled()
+        return [
+            StoreKitCatalogEntry(
+                productID: config.plans.monthly.productID,
+                displayName: config.plans.monthly.title,
+                kind: .monthly,
+                sortOrder: 20
+            ),
+            StoreKitCatalogEntry(
+                productID: config.plans.yearly.productID,
+                displayName: config.plans.yearly.title,
+                kind: .yearly,
+                sortOrder: 30
+            ),
+            StoreKitCatalogEntry(
+                productID: config.plans.lifetime.productID,
+                displayName: config.plans.lifetime.title,
+                kind: .lifetime,
+                sortOrder: 1_000
+            ),
+        ]
+    }
+}

+ 1 - 1
smart_printer/UIComponents.swift

@@ -263,7 +263,7 @@ final class SidebarPremiumCardView: NSView, AppearanceRefreshable {
 
         let actionTitle: String
         if isPremium {
-            actionTitle = activePlan == .lifetime
+            actionTitle = activePlan?.isLifetime == true
                 ? sidebar.lifetimeAction
                 : sidebar.manageSubscriptionAction
         } else {

+ 87 - 0
smart_printer/Utilities/PremiumAccess.swift

@@ -0,0 +1,87 @@
+import Cocoa
+
+enum PremiumAccess {
+    @MainActor
+    static func canAccess(_ feature: AppFeature) -> Bool {
+        let store = StoreManager.shared
+        return FreeTierManager.canAccess(feature, isPremium: store.isPremium)
+    }
+
+    @MainActor
+    @discardableResult
+    static func require(
+        feature: AppFeature,
+        from window: NSWindow?,
+        returnToHomeOnDismiss: Bool = false
+    ) -> Bool {
+        guard canAccess(feature) else {
+            presentPaywallOrLimitAlert(for: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
+            return false
+        }
+        return true
+    }
+
+    @MainActor
+    @discardableResult
+    static func requireForAction(
+        feature: AppFeature,
+        from window: NSWindow?,
+        returnToHomeOnDismiss: Bool = false,
+        recordFreeUse: Bool = true
+    ) -> Bool {
+        guard require(feature: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss) else {
+            return false
+        }
+        if recordFreeUse {
+            recordFreeUseIfNeeded()
+        }
+        return true
+    }
+
+    @MainActor
+    static func recordFreeUseIfNeeded() {
+        guard !StoreManager.shared.isPremium else { return }
+        FreeTierManager.recordUseIfNeeded(isPremium: false)
+    }
+
+    @MainActor
+    @discardableResult
+    static func require(from window: NSWindow?, returnToHomeOnDismiss: Bool = false) -> Bool {
+        require(feature: .documentImport, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
+    }
+
+    @MainActor
+    private static func presentPaywallOrLimitAlert(
+        for feature: AppFeature,
+        from window: NSWindow?,
+        returnToHomeOnDismiss: Bool
+    ) {
+        if StoreManager.shared.isResolvingEntitlements {
+            Task { @MainActor in
+                await StoreManager.shared.ensureEntitlementsResolved()
+                guard !canAccess(feature) else { return }
+                presentPaywallOrLimitAlert(for: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
+            }
+            return
+        }
+
+        guard let viewController = mainViewController(from: window) else { return }
+        viewController.presentPaywall(returnToHomeOnDismiss: returnToHomeOnDismiss)
+    }
+
+    @MainActor
+    static func mainViewController(from window: NSWindow?) -> ViewController? {
+        if let viewController = window?.contentViewController as? ViewController {
+            return viewController
+        }
+        if let viewController = NSApp.keyWindow?.contentViewController as? ViewController {
+            return viewController
+        }
+        for appWindow in NSApp.windows where appWindow.canBecomeMain {
+            if let viewController = appWindow.contentViewController as? ViewController {
+                return viewController
+            }
+        }
+        return nil
+    }
+}

+ 6 - 2
smart_printer/ViewController.swift

@@ -527,8 +527,12 @@ class ViewController: NSViewController {
 
         let isSettings = destination == .settings
         headerView.isHidden = isSettings
-        contentTopBelowHeader.isActive = !isSettings
-        contentTopBelowWindow.isActive = isSettings
+        NSLayoutConstraint.deactivate([contentTopBelowHeader, contentTopBelowWindow])
+        if isSettings {
+            contentTopBelowWindow.isActive = true
+        } else {
+            contentTopBelowHeader.isActive = true
+        }
     }
 
     func presentPaywall(returnToHomeOnDismiss: Bool = true) {

+ 12 - 10
smart_printer/paywall.json

@@ -14,7 +14,7 @@
     {
       "icon": "shield.fill",
       "title": "Secure Payments",
-      "subtitle": "Your payment is 100% secure"
+      "subtitle": "Payments are securely handled by Apple."
     },
     {
       "icon": "arrow.counterclockwise",
@@ -23,13 +23,13 @@
     },
     {
       "icon": "headphones",
-      "title": "24/7 Support",
-      "subtitle": "We're here to help you anytime."
+      "title": "Customer Support",
+      "subtitle": "Contact us through our Support page."
     },
     {
       "icon": "lock.fill",
       "title": "Privacy First",
-      "subtitle": "Your data is safe with us."
+      "subtitle": "Handled according to our Privacy Policy."
     }
   ],
   "lifetimeTrustItem": {
@@ -39,10 +39,11 @@
   },
   "plans": {
     "monthly": {
+      "productID": "com.printer_app_all_printers.premium.monthly",
       "title": "Monthly",
       "subtitleTemplate": "{price} / month, cancel anytime",
       "ctaTemplate": "Subscribe for {price} / Month",
-      "fallbackPrice": "$4.99",
+      "fallbackPrice": "",
       "priceSuffix": "/mo",
       "fallbackBillingDescription": "Billed at {price} every month",
       "features": [
@@ -52,11 +53,12 @@
       ]
     },
     "yearly": {
+      "productID": "com.printer_app_all_printers.premium.yearly",
       "title": "Yearly",
       "subtitleTemplate": "{price} / year, cancel anytime",
       "trialSubtitleTemplate": "{trial} free, then {price} / year",
       "ctaTemplate": "Subscribe for {price} / Year",
-      "fallbackPrice": "$29.99",
+      "fallbackPrice": "",
       "priceSuffix": "/yr",
       "fallbackBillingDescription": "Billed at {price} every year",
       "features": [
@@ -66,11 +68,11 @@
       ]
     },
     "lifetime": {
+      "productID": "com.printer_app_all_printers.premium.lifetime",
       "title": "Lifetime",
-      "badge": "Best Value",
       "subtitleTemplate": "{price} once, lifetime access",
       "ctaTemplate": "Buy Lifetime Access for {price}",
-      "fallbackPrice": "$99.99",
+      "fallbackPrice": "",
       "priceSuffix": null,
       "fallbackBillingDescription": "One-time payment of {price}",
       "features": [
@@ -99,8 +101,8 @@
     "badgeTemplate": "{duration} Free Trial",
     "ctaTemplate": "Start {duration} Free Trial",
     "fallbackDuration": {
-      "count": 1,
-      "unit": "week"
+      "count": 3,
+      "unit": "day"
     }
   },
   "duration": {

+ 0 - 6
smart_printer/smart_printer.entitlements

@@ -10,15 +10,9 @@
 	<true/>
 	<key>com.apple.security.network.client</key>
 	<true/>
-	<key>com.apple.security.network.server</key>
-	<true/>
 	<key>com.apple.security.print</key>
 	<true/>
 	<key>com.apple.security.personal-information.addressbook</key>
 	<true/>
-	<key>com.apple.security.device.scanner</key>
-	<true/>
-	<key>com.apple.security.device.usb</key>
-	<true/>
 </dict>
 </plist>

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott