From 0f7ee6e29f70017dd4fdfe2dd3a6106f7204f04f Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 02 Jul 2026 02:40:40 +0800
Subject: [PATCH] Document Raycast extension backlog

---
 src/Apptag/ProEntitlement.swift |  201 +++++++++++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 196 insertions(+), 5 deletions(-)

diff --git a/src/Apptag/ProEntitlement.swift b/src/Apptag/ProEntitlement.swift
index e1c8c5e..b0b6f46 100644
--- a/src/Apptag/ProEntitlement.swift
+++ b/src/Apptag/ProEntitlement.swift
@@ -6,8 +6,11 @@
     case premiumThemes
     case layoutImport
     case layoutExport
+    case customTagColors
+    case customContainerQuota
     case unlimitedNotes
     case persistentAppSorting
+    case customHotkeys
 
     var titleKey: String {
         switch self {
@@ -17,10 +20,16 @@
             return "pro.feature.import"
         case .layoutExport:
             return "pro.feature.export"
+        case .customTagColors:
+            return "pro.feature.customTagColors"
+        case .customContainerQuota:
+            return "pro.feature.customContainerQuota"
         case .unlimitedNotes:
             return "pro.feature.notes"
         case .persistentAppSorting:
             return "pro.feature.sorting"
+        case .customHotkeys:
+            return "pro.feature.customHotkeys"
         }
     }
 
@@ -32,10 +41,16 @@
             return "pro.feature.import.benefit"
         case .layoutExport:
             return "pro.feature.export.benefit"
+        case .customTagColors:
+            return "pro.feature.customTagColors.benefit"
+        case .customContainerQuota:
+            return "pro.feature.customContainerQuota.benefit"
         case .unlimitedNotes:
             return "pro.feature.notes.benefit"
         case .persistentAppSorting:
             return "pro.feature.sorting.benefit"
+        case .customHotkeys:
+            return "pro.feature.customHotkeys.benefit"
         }
     }
 }
@@ -95,8 +110,23 @@
 
 struct ProThemePreviewState: Equatable {
     let theme: AppGridTheme
+    let themeTuning: Double?
     let startedAt: Date
     let endsAt: Date
+
+    func remainingSeconds(at date: Date = Date()) -> Int {
+        max(0, Int(ceil(endsAt.timeIntervalSince(date))))
+    }
+}
+
+struct ProDisplayModePreviewState: Equatable {
+    let displayMode: String
+    let startedAt: Date
+    let endsAt: Date
+
+    func remainingSeconds(at date: Date = Date()) -> Int {
+        max(0, Int(ceil(endsAt.timeIntervalSince(date))))
+    }
 }
 
 struct ProNoteQuotaStatus: Equatable {
@@ -114,14 +144,30 @@
     case blocked(ProNoteQuotaStatus)
 }
 
+struct ProCustomContainerQuotaStatus: Equatable {
+    let isUnlimited: Bool
+    let used: Int
+    let limit: Int
+
+    var remaining: Int {
+        max(0, limit - used)
+    }
+}
+
+enum ProCustomContainerMaintenanceDecision: Equatable {
+    case allow(ProCustomContainerQuotaStatus)
+    case blocked(ProCustomContainerQuotaStatus)
+}
+
 enum ProEntitlementConfig {
     // 发布前只改这里,不允许把商品信息和边界版本散落到业务模块。
     static let lifetimeProductID = "com.taglauncher.pro.lifetime"
     static let firstFreeProVersion = "8.1.0"
     static let legacyFallbackOriginalPurchaseDateISO8601: String? = nil
 
-    static let freeThemes: Set<AppGridTheme> = [.defaultLight, .deepBlue, .black]
-    static let freeNoteLimit = 5
+    static let freeThemes: Set<AppGridTheme> = [.defaultLight, .black]
+    static let freeNoteLimit = 3
+    static let freeCustomContainerLimit = 6
     static let defaultThemePreviewDuration: TimeInterval = 5 * 60
 
     static let qaStateEnvKey = "TAGLAUNCHER_QA_PRO_STATE"
@@ -159,6 +205,8 @@
 private struct ProEntitlementSnapshot {
     var accessState: ProAccessState = .free
     var previewThemeID: String? = nil
+    var previewThemeTuning: Double? = nil
+    var previewDisplayMode: String? = nil
 }
 
 private enum ProEntitlementSnapshotStore {
@@ -180,6 +228,8 @@
 }
 
 enum ProEntitlementPolicy {
+    static let premiumDisplayModes: Set<String> = ["coloredContainer", "coloredGridContainer"]
+
     static func accessState() -> ProAccessState {
         ProEntitlementSnapshotStore.read().accessState
     }
@@ -196,13 +246,44 @@
 
     static func isUnlocked(_ feature: ProFeature) -> Bool {
         switch feature {
-        case .premiumThemes, .layoutImport, .layoutExport, .unlimitedNotes, .persistentAppSorting:
+        case .premiumThemes, .layoutImport, .layoutExport, .customTagColors, .customContainerQuota, .unlimitedNotes, .persistentAppSorting, .customHotkeys:
             return accessState().isUnlocked
         }
     }
 
     static func canUseTheme(_ theme: AppGridTheme) -> Bool {
         ProEntitlementConfig.freeThemes.contains(theme) || isUnlocked(.premiumThemes)
+    }
+
+    static func isPremiumDisplayMode(_ displayMode: String) -> Bool {
+        premiumDisplayModes.contains(displayMode)
+    }
+
+    static func canUseDisplayMode(_ displayMode: String) -> Bool {
+        !isPremiumDisplayMode(displayMode) || isUnlocked(.premiumThemes)
+    }
+
+    static func fallbackDisplayMode(for displayMode: String) -> String {
+        switch displayMode {
+        case "coloredContainer":
+            return "container"
+        case "coloredGridContainer":
+            return "gridContainer"
+        default:
+            return displayMode
+        }
+    }
+
+    static func effectiveDisplayMode(for storedDisplayMode: String) -> String {
+        let snapshot = ProEntitlementSnapshotStore.read()
+        if let previewDisplayMode = snapshot.previewDisplayMode,
+           isPremiumDisplayMode(previewDisplayMode) {
+            return previewDisplayMode
+        }
+        guard canUseDisplayMode(storedDisplayMode) else {
+            return fallbackDisplayMode(for: storedDisplayMode)
+        }
+        return storedDisplayMode
     }
 
     static func effectiveTheme(for storedTheme: AppGridTheme) -> AppGridTheme {
@@ -214,6 +295,19 @@
             return .defaultLight
         }
         return storedTheme
+    }
+
+    static func effectiveThemeTuning(for storedTheme: AppGridTheme, storedTuning: Double?) -> Double? {
+        let snapshot = ProEntitlementSnapshotStore.read()
+        if let previewID = snapshot.previewThemeID,
+           let previewTheme = AppGridTheme(rawValue: previewID),
+           previewTheme.supportsColorTuning {
+            return previewTheme.normalizedTuning(snapshot.previewThemeTuning)
+        }
+        guard canUseTheme(storedTheme), storedTheme.supportsColorTuning else {
+            return nil
+        }
+        return storedTheme.normalizedTuning(storedTuning)
     }
 
     static func noteQuotaStatus(in store: TagDatabase.Store? = nil) -> ProNoteQuotaStatus {
@@ -271,6 +365,37 @@
             throw ProEntitlementError.noteQuotaExceeded(status)
         }
     }
+
+    static func customContainerQuotaStatus(in store: TagDatabase.Store? = nil) -> ProCustomContainerQuotaStatus {
+        guard !isUnlocked(.customContainerQuota) else {
+            return ProCustomContainerQuotaStatus(isUnlimited: true, used: 0, limit: Int.max)
+        }
+        let used = TagDatabase.customMaintainedContainerCount(in: store)
+        return ProCustomContainerQuotaStatus(
+            isUnlimited: false,
+            used: used,
+            limit: ProEntitlementConfig.freeCustomContainerLimit
+        )
+    }
+
+    static func customContainerMaintenanceDecision(
+        addingCustomContainerCount addedCount: Int,
+        in store: TagDatabase.Store? = nil
+    ) -> ProCustomContainerMaintenanceDecision {
+        let status = customContainerQuotaStatus(in: store)
+        guard !status.isUnlimited else { return .allow(status) }
+        guard addedCount > 0 else { return .allow(status) }
+        guard status.used + addedCount <= status.limit else {
+            return .blocked(status)
+        }
+        return .allow(
+            ProCustomContainerQuotaStatus(
+                isUnlimited: false,
+                used: status.used + addedCount,
+                limit: status.limit
+            )
+        )
+    }
 }
 
 @MainActor
@@ -281,17 +406,20 @@
     @Published private(set) var operationState: ProOperationState = .idle
     @Published private(set) var priceDisplayText: String? = nil
     @Published private(set) var themePreviewState: ProThemePreviewState? = nil
+    @Published private(set) var displayModePreviewState: ProDisplayModePreviewState? = nil
 
     private var hasStarted = false
     private var cachedProduct: Product?
     private var updateTask: Task<Void, Never>?
     private var themePreviewTask: Task<Void, Never>?
+    private var displayModePreviewTask: Task<Void, Never>?
 
     private init() {}
 
     deinit {
         updateTask?.cancel()
         themePreviewTask?.cancel()
+        displayModePreviewTask?.cancel()
     }
 
     var isUnlocked: Bool {
@@ -302,8 +430,12 @@
         themePreviewState != nil
     }
 
+    var isPreviewingProAppearance: Bool {
+        themePreviewState != nil || displayModePreviewState != nil
+    }
+
     var compactStatusTextKey: String {
-        if themePreviewState != nil {
+        if isPreviewingProAppearance {
             return "pro.theme.preview.status"
         }
         switch accessState {
@@ -397,14 +529,16 @@
         }
     }
 
-    func startThemePreview(for theme: AppGridTheme) {
+    func startThemePreview(for theme: AppGridTheme, tuning: Double? = nil) {
         guard !ProEntitlementConfig.freeThemes.contains(theme) else { return }
         guard !accessState.isUnlocked else { return }
 
+        stopDisplayModePreview()
         themePreviewTask?.cancel()
 
         let preview = ProThemePreviewState(
             theme: theme,
+            themeTuning: theme.supportsColorTuning ? theme.normalizedTuning(tuning) : nil,
             startedAt: Date(),
             endsAt: Date().addingTimeInterval(ProEntitlementConfig.themePreviewDuration)
         )
@@ -418,10 +552,54 @@
         }
     }
 
+    func updateThemePreviewTuning(_ tuning: Double, for theme: AppGridTheme) {
+        guard let preview = themePreviewState,
+              preview.theme == theme,
+              theme.supportsColorTuning,
+              !accessState.isUnlocked
+        else { return }
+        let updatedPreview = ProThemePreviewState(
+            theme: preview.theme,
+            themeTuning: theme.normalizedTuning(tuning),
+            startedAt: preview.startedAt,
+            endsAt: preview.endsAt
+        )
+        applyThemePreview(updatedPreview)
+    }
+
     func stopThemePreview() {
         themePreviewTask?.cancel()
         themePreviewTask = nil
         applyThemePreview(nil)
+    }
+
+    func startDisplayModePreview(for displayMode: String) {
+        guard ProEntitlementPolicy.isPremiumDisplayMode(displayMode) else { return }
+        guard !accessState.isUnlocked else { return }
+
+        stopThemePreview()
+        displayModePreviewTask?.cancel()
+
+        let now = Date()
+        let preview = ProDisplayModePreviewState(
+            displayMode: displayMode,
+            startedAt: now,
+            endsAt: now.addingTimeInterval(ProEntitlementConfig.themePreviewDuration)
+        )
+        applyDisplayModePreview(preview)
+
+        displayModePreviewTask = Task { [weak self] in
+            let duration = max(1, ProEntitlementConfig.themePreviewDuration)
+            try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
+            guard !Task.isCancelled else { return }
+            self?.stopDisplayModePreview()
+        }
+    }
+
+    func stopDisplayModePreview() {
+        displayModePreviewTask?.cancel()
+        displayModePreviewTask = nil
+        applyDisplayModePreview(nil)
     }
 
     private func startTransactionUpdates() {
@@ -534,9 +712,14 @@
         originalAppVersion: String? = nil,
         originalPurchaseDate: Date? = nil
     ) {
+        let previousState = accessState
         accessState = state
         ProEntitlementSnapshotStore.update { snapshot in
             snapshot.accessState = state
+        }
+
+        if previousState != state {
+            NotificationCenter.default.post(name: .tagLauncherProEntitlementChanged, object: nil)
         }
 
         if persistCache {
@@ -555,6 +738,14 @@
         themePreviewState = preview
         ProEntitlementSnapshotStore.update { snapshot in
             snapshot.previewThemeID = preview?.theme.rawValue
+            snapshot.previewThemeTuning = preview?.themeTuning
+        }
+    }
+
+    private func applyDisplayModePreview(_ preview: ProDisplayModePreviewState?) {
+        displayModePreviewState = preview
+        ProEntitlementSnapshotStore.update { snapshot in
+            snapshot.previewDisplayMode = preview?.displayMode
         }
     }
 

--
Gitblit v1.9.3