Răsfoiți Sursa

Improve App Store compliance with a free tier and privacy manifest.

Add Print Text plus one daily scan for free users, keep legal paywall copy bundled, and document local network access in the privacy policy.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 3 săptămâni în urmă
părinte
comite
174f89a5bc

+ 1 - 1
smart_printer/DocumentPickerService.swift

@@ -21,7 +21,7 @@ enum DocumentPickerService {
 
     static func present(_ kind: DocumentPickerKind, from window: NSWindow?) {
         let hostWindow = window ?? NSApp.keyWindow
-        guard PremiumAccess.require(from: hostWindow) else { return }
+        guard PremiumAccess.require(feature: .documentImport, from: hostWindow) else { return }
 
         let panel = NSOpenPanel()
         panel.canChooseDirectories = false

+ 1 - 1
smart_printer/DrawPrintView.swift

@@ -6,7 +6,7 @@ import UniformTypeIdentifiers
 enum DrawPrintService {
     static func present(from window: NSWindow?) {
         let hostWindow = window ?? NSApp.keyWindow
-        guard PremiumAccess.require(from: hostWindow) else { return }
+        guard PremiumAccess.require(feature: .drawPrint, from: hostWindow) else { return }
         guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
         viewController.presentDrawPrint()
     }

+ 1 - 1
smart_printer/FilePreviewView.swift

@@ -8,7 +8,7 @@ enum FilePreviewService {
     static func present(urls: [URL], from window: NSWindow?) {
         guard !urls.isEmpty else { return }
         let hostWindow = window ?? NSApp.keyWindow
-        guard PremiumAccess.require(from: hostWindow) else { return }
+        guard PremiumAccess.require(feature: .filePreview, from: hostWindow) else { return }
         guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
         viewController.presentFilePreview(urls: urls)
     }

+ 75 - 0
smart_printer/FreeTierManager.swift

@@ -0,0 +1,75 @@
+import Foundation
+
+/// Features available without a Premium subscription.
+enum AppFeature: Sendable {
+    case printText
+    case scannerScan
+    case scanImport
+    case printContacts
+    case drawPrint
+    case ocrFile
+    case photoPreview
+    case filePreview
+    case documentImport
+}
+
+@MainActor
+enum FreeTierManager {
+    static let dailyFreeScanLimit = 1
+
+    private enum Key {
+        static let scanDay = "freeTier.scanDay"
+        static let scanCount = "freeTier.scanCount"
+    }
+
+    private static let defaults = UserDefaults.standard
+    private static let dayFormatter: DateFormatter = {
+        let formatter = DateFormatter()
+        formatter.calendar = Calendar.current
+        formatter.locale = Locale(identifier: "en_US_POSIX")
+        formatter.dateFormat = "yyyy-MM-dd"
+        return formatter
+    }()
+
+    static func canAccess(_ feature: AppFeature, isPremium: Bool) -> Bool {
+        if isPremium { return true }
+
+        switch feature {
+        case .printText:
+            return true
+        case .scannerScan:
+            return remainingScansToday(isPremium: false) > 0
+        case .scanImport, .printContacts, .drawPrint, .ocrFile, .photoPreview, .filePreview, .documentImport:
+            return false
+        }
+    }
+
+    static func remainingScansToday(isPremium: Bool) -> Int {
+        guard !isPremium else { return .max }
+        normalizeScanUsageForToday()
+        let used = defaults.integer(forKey: Key.scanCount)
+        return max(0, dailyFreeScanLimit - used)
+    }
+
+    static func recordScannerScanIfNeeded(isPremium: Bool) {
+        guard !isPremium else { return }
+        normalizeScanUsageForToday()
+        let used = defaults.integer(forKey: Key.scanCount)
+        defaults.set(used + 1, forKey: Key.scanCount)
+    }
+
+    static func scanLimitReachedMessage() -> (title: String, message: String) {
+        (
+            title: "Daily Free Scan Used",
+            message: "You've used today's free scan. Upgrade to Premium for unlimited scans, or try again tomorrow."
+        )
+    }
+
+    private static func normalizeScanUsageForToday() {
+        let today = dayFormatter.string(from: Date())
+        let storedDay = defaults.string(forKey: Key.scanDay)
+        guard storedDay != today else { return }
+        defaults.set(today, forKey: Key.scanDay)
+        defaults.set(0, forKey: Key.scanCount)
+    }
+}

+ 1 - 1
smart_printer/OCRFileView.swift

@@ -28,7 +28,7 @@ enum OCRFileService {
 
     static func present(from window: NSWindow?) {
         let hostWindow = window ?? NSApp.keyWindow
-        guard PremiumAccess.require(from: hostWindow) else { return }
+        guard PremiumAccess.require(feature: .ocrFile, from: hostWindow) else { return }
 
         let panel = NSOpenPanel()
         panel.title = "OCR File"

+ 25 - 0
smart_printer/PaywallConfig.swift

@@ -125,6 +125,31 @@ struct PaywallConfig: Codable, Sendable {
 }
 
 extension PaywallConfig {
+    /// Applies remote marketing copy only. Legal URLs and subscription disclosures stay bundled.
+    func mergingMarketing(from remote: PaywallConfig) -> PaywallConfig {
+        PaywallConfig(
+            leftPanelTitle: remote.leftPanelTitle,
+            rightTitle: remote.rightTitle,
+            rightSubtitle: remote.rightSubtitle,
+            features: remote.features,
+            trustItems: remote.trustItems,
+            lifetimeTrustItem: remote.lifetimeTrustItem,
+            plans: remote.plans,
+            footer: footer,
+            urls: urls,
+            trial: trial,
+            duration: duration,
+            messages: messages,
+            sidebar: remote.sidebar,
+            loadingPrice: loadingPrice,
+            loadingCTA: loadingCTA,
+            subscriptionDisclosureTemplate: subscriptionDisclosureTemplate,
+            trialDisclosureTemplate: trialDisclosureTemplate,
+            lifetimeDisclosure: lifetimeDisclosure,
+            loadingDisclosure: loadingDisclosure
+        )
+    }
+
     static func loadBundled() -> PaywallConfig {
         guard let url = Bundle.main.url(forResource: "paywall", withExtension: "json"),
               let data = try? Data(contentsOf: url),

+ 5 - 3
smart_printer/PaywallConfigService.swift

@@ -38,9 +38,11 @@ final class PaywallConfigService {
         }
     }
 
-    private func apply(_ newConfig: PaywallConfig) {
-        guard newConfig != config else { return }
-        config = newConfig
+    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)
     }
 }

+ 36 - 10
smart_printer/PaywallView.swift

@@ -442,30 +442,56 @@ extension Notification.Name {
 
 enum PremiumAccess {
     @MainActor
-    static var isGranted: Bool {
+    static func canAccess(_ feature: AppFeature) -> Bool {
         let store = StoreManager.shared
-        guard !store.isResolvingEntitlements else { return false }
-        return store.isPremium
+        guard !store.isResolvingEntitlements else { return feature == .printText }
+        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 require(from window: NSWindow?, returnToHomeOnDismiss: Bool = false) -> Bool {
-        guard !isGranted else { return true }
+        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 !isGranted else { return }
-                guard let viewController = mainViewController(from: window) else { return }
-                viewController.presentPaywall(returnToHomeOnDismiss: returnToHomeOnDismiss)
+                guard !canAccess(feature) else { return }
+                presentPaywallOrLimitAlert(for: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
             }
-            return false
+            return
         }
 
-        guard let viewController = mainViewController(from: window) else { return false }
+        if feature == .scannerScan,
+           !StoreManager.shared.isPremium,
+           FreeTierManager.remainingScansToday(isPremium: false) == 0 {
+            let alert = FreeTierManager.scanLimitReachedMessage()
+            StoreManager.shared.showAlert(title: alert.title, message: alert.message, on: window)
+        }
+
+        guard let viewController = mainViewController(from: window) else { return }
         viewController.presentPaywall(returnToHomeOnDismiss: returnToHomeOnDismiss)
-        return false
     }
 
     @MainActor

+ 1 - 1
smart_printer/PhotoPreviewView.swift

@@ -6,7 +6,7 @@ enum PhotoPreviewService {
     static func present(urls: [URL], from window: NSWindow?) {
         guard !urls.isEmpty else { return }
         let hostWindow = window ?? NSApp.keyWindow
-        guard PremiumAccess.require(from: hostWindow) else { return }
+        guard PremiumAccess.require(feature: .photoPreview, from: hostWindow) else { return }
         guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
         viewController.presentPhotoPreview(urls: urls)
     }

+ 1 - 1
smart_printer/PrintContactsView.swift

@@ -155,7 +155,7 @@ enum ContactsService {
 enum PrintContactsService {
     static func present(from window: NSWindow?) {
         let hostWindow = window ?? NSApp.keyWindow
-        guard PremiumAccess.require(from: hostWindow) else { return }
+        guard PremiumAccess.require(feature: .printContacts, from: hostWindow) else { return }
         guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
         viewController.presentPrintContacts()
     }

+ 1 - 1
smart_printer/PrintTextView.swift

@@ -5,7 +5,7 @@ import Cocoa
 enum PrintTextService {
     static func present(from window: NSWindow?) {
         let hostWindow = window ?? NSApp.keyWindow
-        guard PremiumAccess.require(from: hostWindow) else { return }
+        guard PremiumAccess.require(feature: .printText, from: hostWindow) else { return }
         guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
         viewController.presentPrintText()
     }

+ 29 - 0
smart_printer/PrivacyInfo.xcprivacy

@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>NSPrivacyTracking</key>
+	<false/>
+	<key>NSPrivacyCollectedDataTypes</key>
+	<array/>
+	<key>NSPrivacyAccessedAPITypes</key>
+	<array>
+		<dict>
+			<key>NSPrivacyAccessedAPIType</key>
+			<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
+			<key>NSPrivacyAccessedAPITypeReasons</key>
+			<array>
+				<string>CA92.1</string>
+			</array>
+		</dict>
+		<dict>
+			<key>NSPrivacyAccessedAPIType</key>
+			<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
+			<key>NSPrivacyAccessedAPITypeReasons</key>
+			<array>
+				<string>C617.1</string>
+			</array>
+		</dict>
+	</array>
+</dict>
+</plist>

+ 39 - 12
smart_printer/ScanFileView.swift

@@ -33,15 +33,32 @@ enum ScanError: LocalizedError {
 enum ScanFileService {
     static func present(from window: NSWindow?) {
         let hostWindow = window ?? NSApp.keyWindow
-        guard PremiumAccess.require(from: hostWindow) else { return }
-        showSourcePicker(from: hostWindow) { source in
-            guard let source else { return }
-            switch source {
-            case .scanner:
-                startScannerScan(from: hostWindow)
-            case .importFile:
-                importFile(from: hostWindow)
+
+        if StoreManager.shared.isPremium {
+            showSourcePicker(from: hostWindow, allowsImport: true) { source in
+                guard let source else { return }
+                handleScanSource(source, from: hostWindow)
             }
+            return
+        }
+
+        if FreeTierManager.remainingScansToday(isPremium: false) > 0 {
+            showSourcePicker(from: hostWindow, allowsImport: false) { source in
+                guard let source else { return }
+                handleScanSource(source, from: hostWindow)
+            }
+            return
+        }
+
+        guard PremiumAccess.require(feature: .scannerScan, from: hostWindow) else { return }
+    }
+
+    private static func handleScanSource(_ source: ScanSource, from window: NSWindow?) {
+        switch source {
+        case .scanner:
+            startScannerScan(from: window)
+        case .importFile:
+            importFile(from: window)
         }
     }
 
@@ -50,20 +67,30 @@ enum ScanFileService {
         case importFile
     }
 
-    private static func showSourcePicker(from window: NSWindow?, completion: @escaping (ScanSource?) -> Void) {
+    private static func showSourcePicker(
+        from window: NSWindow?,
+        allowsImport: Bool,
+        completion: @escaping (ScanSource?) -> Void
+    ) {
         let alert = NSAlert()
         alert.messageText = "Scan Document"
-        alert.informativeText = "Scan with a connected scanner, or import an image or PDF."
+        if allowsImport {
+            alert.informativeText = "Scan with a connected scanner, or import an image or PDF."
+        } else {
+            alert.informativeText = "Use your free daily scan with a connected scanner. Import and unlimited scans are available with Premium."
+        }
         alert.alertStyle = .informational
         alert.addButton(withTitle: "Use Scanner")
-        alert.addButton(withTitle: "Import File")
+        if allowsImport {
+            alert.addButton(withTitle: "Import File")
+        }
         alert.addButton(withTitle: "Cancel")
 
         let handler: (NSApplication.ModalResponse) -> Void = { response in
             switch response {
             case .alertFirstButtonReturn:
                 completion(.scanner)
-            case .alertSecondButtonReturn:
+            case .alertSecondButtonReturn where allowsImport:
                 completion(.importFile)
             default:
                 completion(nil)

+ 29 - 32
smart_printer/ViewController.swift

@@ -26,6 +26,7 @@ class ViewController: NSViewController {
     private var scanFileOverlay: ScanFileOverlayView?
     private var scannerOverlay: ScannerOverlayView?
     private var scannerDidComplete = false
+    private var didRecordFreeScanThisSession = false
 
     private var headerView: NSView!
     private var contentTopBelowHeader: NSLayoutConstraint!
@@ -115,7 +116,7 @@ class ViewController: NSViewController {
     }
 
     func presentScanner(showScanPreview: Bool = false, onComplete: @escaping (Result<NSImage, Error>) -> Void) {
-        guard guardPremiumFeature() else { return }
+        guard guardPremiumFeature(.scannerScan) else { return }
         photoPreviewOverlay?.dismiss(animated: false)
         filePreviewOverlay?.dismiss(animated: false)
         printTextOverlay?.dismiss(animated: false)
@@ -125,6 +126,7 @@ class ViewController: NSViewController {
         scanFileOverlay?.dismiss(animated: false)
 
         scannerDidComplete = false
+        didRecordFreeScanThisSession = false
 
         if let existing = scannerOverlay, existing.superview != nil {
             configureScannerOverlay(existing, showScanPreview: showScanPreview, onComplete: onComplete)
@@ -155,7 +157,8 @@ class ViewController: NSViewController {
             }
         }
         overlay.onScanComplete = { [weak self] image in
-            Task { @MainActor in
+            Task { @MainActor [weak self] in
+                guard let self else { return }
                 let processed: NSImage
                 if let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) {
                     processed = await ScanDocumentProcessor.process(cgImage) ?? image
@@ -165,9 +168,18 @@ class ViewController: NSViewController {
 
                 let displayImage = processed.normalizedForDisplay()
                 if showScanPreview {
+                    if !self.didRecordFreeScanThisSession {
+                        FreeTierManager.recordScannerScanIfNeeded(
+                            isPremium: StoreManager.shared.isPremium
+                        )
+                        self.didRecordFreeScanThisSession = true
+                    }
                     overlay.updateScanPreview(displayImage)
                 } else {
-                    self?.scannerDidComplete = true
+                    self.scannerDidComplete = true
+                    FreeTierManager.recordScannerScanIfNeeded(
+                        isPremium: StoreManager.shared.isPremium
+                    )
                     onComplete(.success(displayImage))
                     overlay.dismiss()
                 }
@@ -176,7 +188,7 @@ class ViewController: NSViewController {
     }
 
     func presentPrintText() {
-        guard guardPremiumFeature() else { return }
+        guard guardPremiumFeature(.printText) else { return }
         photoPreviewOverlay?.dismiss(animated: false)
         filePreviewOverlay?.dismiss(animated: false)
         printContactsOverlay?.dismiss(animated: false)
@@ -194,7 +206,7 @@ class ViewController: NSViewController {
     }
 
     func presentPrintContacts() {
-        guard guardPremiumFeature() else { return }
+        guard guardPremiumFeature(.printContacts) else { return }
         photoPreviewOverlay?.dismiss(animated: false)
         filePreviewOverlay?.dismiss(animated: false)
         printTextOverlay?.dismiss(animated: false)
@@ -220,7 +232,7 @@ class ViewController: NSViewController {
     }
 
     func presentOCRFile(imageURL: URL) {
-        guard guardPremiumFeature() else { return }
+        guard guardPremiumFeature(.ocrFile) else { return }
         photoPreviewOverlay?.dismiss(animated: false)
         filePreviewOverlay?.dismiss(animated: false)
         printTextOverlay?.dismiss(animated: false)
@@ -238,7 +250,6 @@ class ViewController: NSViewController {
     }
 
     func presentScanFile(pages: [ScanPage]) {
-        guard guardPremiumFeature() else { return }
         photoPreviewOverlay?.dismiss(animated: false)
         filePreviewOverlay?.dismiss(animated: false)
         printTextOverlay?.dismiss(animated: false)
@@ -265,7 +276,7 @@ class ViewController: NSViewController {
     }
 
     func presentDrawPrint() {
-        guard guardPremiumFeature() else { return }
+        guard guardPremiumFeature(.drawPrint) else { return }
         photoPreviewOverlay?.dismiss(animated: false)
         filePreviewOverlay?.dismiss(animated: false)
         printTextOverlay?.dismiss(animated: false)
@@ -283,7 +294,7 @@ class ViewController: NSViewController {
     }
 
     func presentPhotoPreview(urls: [URL]) {
-        guard guardPremiumFeature() else { return }
+        guard guardPremiumFeature(.photoPreview) else { return }
         photoPreviewOverlay?.dismiss(animated: false)
         filePreviewOverlay?.dismiss(animated: false)
         ocrFileOverlay?.dismiss(animated: false)
@@ -302,7 +313,7 @@ class ViewController: NSViewController {
     }
 
     func presentFilePreview(urls: [URL]) {
-        guard guardPremiumFeature() else { return }
+        guard guardPremiumFeature(.filePreview) else { return }
         photoPreviewOverlay?.dismiss(animated: false)
         filePreviewOverlay?.dismiss(animated: false)
         ocrFileOverlay?.dismiss(animated: false)
@@ -455,11 +466,6 @@ class ViewController: NSViewController {
     }
 
     private func showDestinationAfterEntitlementCheck(_ destination: SidebarDestination) {
-        if destination == .scan || destination == .print, !StoreManager.shared.isPremium {
-            presentPaywall(returnToHomeOnDismiss: true)
-            return
-        }
-
         dismissFeatureOverlays()
         contentContainer.isHidden = false
 
@@ -475,7 +481,12 @@ class ViewController: NSViewController {
         contentTopBelowWindow.isActive = isSettings
 
         if destination == .scanAndHome {
-            presentPaywall(returnToHomeOnDismiss: true)
+            if StoreManager.shared.isPremium {
+                sidebar.select(.scan)
+                showDestinationAfterEntitlementCheck(.scan)
+            } else {
+                presentPaywall(returnToHomeOnDismiss: true)
+            }
         } else {
             dismissPaywall()
         }
@@ -504,22 +515,8 @@ class ViewController: NSViewController {
         paywallOverlay = overlay
     }
 
-    private func guardPremiumFeature() -> Bool {
-        let store = StoreManager.shared
-        if store.isResolvingEntitlements {
-            Task { @MainActor in
-                await store.ensureEntitlementsResolved()
-                guard !store.isPremium else { return }
-                self.presentPaywall(returnToHomeOnDismiss: false)
-            }
-            return false
-        }
-
-        guard store.isPremium else {
-            presentPaywall(returnToHomeOnDismiss: false)
-            return false
-        }
-        return true
+    private func guardPremiumFeature(_ feature: AppFeature) -> Bool {
+        PremiumAccess.require(feature: feature, from: view.window)
     }
 
     private func dismissPaywall() {

+ 2 - 2
smart_printer/paywall.json

@@ -6,7 +6,7 @@
     "Unlimited high-quality scans",
     "Advanced OCR technology",
     "Draw & print canvas",
-    "Ad-free experience",
+    "Unlimited scans & prints",
     "Priority support",
     "Secure storage"
   ],
@@ -102,7 +102,7 @@
   "sidebar": {
     "unlockTitle": "Unlock Premium",
     "proTitle": "Smart Printer Pro",
-    "unlockDescription": "Get unlimited scans, printing, OCR and all premium features.",
+    "unlockDescription": "Free plan includes Print Text and 1 scan per day. Upgrade for unlimited access.",
     "proDescription": "You have unlimited scans, printing, OCR and all premium features.",
     "upgradeAction": "Upgrade Now",
     "manageSubscriptionAction": "Manage Subscription",

+ 2 - 1
website/privacy.html

@@ -91,7 +91,7 @@
 
     <article class="card">
       <h1>Privacy Policy</h1>
-      <p class="updated">Last updated: June 12, 2026</p>
+      <p class="updated">Last updated: June 29, 2026</p>
 
       <p>Smart Printer ("we", "our", or "us") respects your privacy. This policy explains how we handle information when you use the Smart Printer macOS application.</p>
 
@@ -100,6 +100,7 @@
       <ul>
         <li><strong>Contacts access</strong> — Used only when you choose to print contacts. Contact data stays on your device.</li>
         <li><strong>Camera access</strong> — Used only when you scan documents. Images are processed locally.</li>
+        <li><strong>Local network access</strong> — Used only to discover scanners and printers on your Wi-Fi network. No data is sent to our servers.</li>
         <li><strong>Files you select</strong> — Opened and printed on your device. We do not upload your files.</li>
       </ul>