Ariver
2026-07-01 0ae3a9359e4cdf6ae5b669c4affd91662463f370
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"
        }
    }
}
@@ -104,6 +119,16 @@
    }
}
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 {
    let isUnlimited: Bool
    let used: Int
@@ -119,6 +144,21 @@
    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"
@@ -126,7 +166,8 @@
    static let legacyFallbackOriginalPurchaseDateISO8601: String? = nil
    static let freeThemes: Set<AppGridTheme> = [.defaultLight, .black]
    static let freeNoteLimit = 5
    static let freeNoteLimit = 3
    static let freeCustomContainerLimit = 6
    static let defaultThemePreviewDuration: TimeInterval = 5 * 60
    static let qaStateEnvKey = "TAGLAUNCHER_QA_PRO_STATE"
@@ -165,6 +206,7 @@
    var accessState: ProAccessState = .free
    var previewThemeID: String? = nil
    var previewThemeTuning: Double? = nil
    var previewDisplayMode: String? = nil
}
private enum ProEntitlementSnapshotStore {
@@ -186,6 +228,8 @@
}
enum ProEntitlementPolicy {
    static let premiumDisplayModes: Set<String> = ["coloredContainer", "coloredGridContainer"]
    static func accessState() -> ProAccessState {
        ProEntitlementSnapshotStore.read().accessState
    }
@@ -202,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 {
@@ -290,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
@@ -300,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 {
@@ -321,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 {
@@ -420,6 +533,7 @@
        guard !ProEntitlementConfig.freeThemes.contains(theme) else { return }
        guard !accessState.isUnlocked else { return }
        stopDisplayModePreview()
        themePreviewTask?.cancel()
        let preview = ProThemePreviewState(
@@ -457,6 +571,35 @@
        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() {
@@ -569,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 {
@@ -594,6 +742,13 @@
        }
    }
    private func applyDisplayModePreview(_ preview: ProDisplayModePreviewState?) {
        displayModePreviewState = preview
        ProEntitlementSnapshotStore.update { snapshot in
            snapshot.previewDisplayMode = preview?.displayMode
        }
    }
    private var cacheURL: URL {
        AppIdentity.applicationSupportDirectory
            .appendingPathComponent("pro-entitlement-cache.json")