| | |
| | | case premiumThemes |
| | | case layoutImport |
| | | case layoutExport |
| | | case customTagColors |
| | | case customContainerQuota |
| | | case unlimitedNotes |
| | | case persistentAppSorting |
| | | case customHotkeys |
| | | |
| | | var titleKey: String { |
| | | switch self { |
| | |
| | | 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" |
| | | } |
| | | } |
| | | |
| | |
| | | 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" |
| | | } |
| | | } |
| | | } |
| | |
| | | |
| | | 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 { |
| | |
| | | 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" |
| | |
| | | private struct ProEntitlementSnapshot { |
| | | var accessState: ProAccessState = .free |
| | | var previewThemeID: String? = nil |
| | | var previewThemeTuning: Double? = nil |
| | | var previewDisplayMode: String? = nil |
| | | } |
| | | |
| | | private enum ProEntitlementSnapshotStore { |
| | |
| | | } |
| | | |
| | | enum ProEntitlementPolicy { |
| | | static let premiumDisplayModes: Set<String> = ["coloredContainer", "coloredGridContainer"] |
| | | |
| | | static func accessState() -> ProAccessState { |
| | | ProEntitlementSnapshotStore.read().accessState |
| | | } |
| | |
| | | |
| | | 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 { |
| | |
| | | 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 { |
| | |
| | | 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 |
| | |
| | | @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 { |
| | |
| | | themePreviewState != nil |
| | | } |
| | | |
| | | var isPreviewingProAppearance: Bool { |
| | | themePreviewState != nil || displayModePreviewState != nil |
| | | } |
| | | |
| | | var compactStatusTextKey: String { |
| | | if themePreviewState != nil { |
| | | if isPreviewingProAppearance { |
| | | return "pro.theme.preview.status" |
| | | } |
| | | switch accessState { |
| | |
| | | } |
| | | } |
| | | |
| | | 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) |
| | | ) |
| | |
| | | } |
| | | } |
| | | |
| | | 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() { |
| | |
| | | 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 { |
| | |
| | | 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 |
| | | } |
| | | } |
| | | |