mycode/myApp/HaruDanim/Shared/Premium.swift
songyc macbook 040198beb4 feat(premium): add store-identical Release run scheme and real-purchase priority
- 'Haru_Danim-Store' 공유 스킴 추가: Release 구성으로 실행해 DEBUG 코드
  (프리미엄 테스트 토글·검증용 런치 인자)가 전부 빠진 '스토어에 올릴
  그대로'의 앱을 실기기에서 확인. StoreKit 로컬 구성은 유지되어 실제 결제
  시트가 뜨되 돈은 청구되지 않는다
- 실구매/복원 시 DEBUG 테스트 토글 강제 모드를 자동 해제(실권한 우선),
  개발용 섹션에 '토글 강제 해제' 버튼과 동작 설명 footer 추가
- Localizable.xcstrings는 Xcode 직렬화 포맷 정규화

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-13 16:14:11 +09:00

209 lines
7.5 KiB
Swift

//
// Premium.swift
// Haru_Danim
//
// (CLAUDE.md §7)
// - EntitlementProvider: . Mock( ), StoreKit 2
// Transaction.currentEntitlements PremiumManager .
// - PremiumManager: UI (@Observable)
// - PremiumGate: ·· (App Group UserDefaults)
//
import Foundation
import Observation
// MARK: - App Group
/// ·· App Group
nonisolated enum AppGroup {
static let identifier = "group.com.yechan.HaruDanim"
/// : @AppStorage(store:)
/// UserDefaults
/// ( )
static let defaults: UserDefaults = UserDefaults(suiteName: identifier) ?? .standard
}
// MARK: - /
/// (CLAUDE.md §7.1)
nonisolated enum FreeLimits {
static let actions = 10
static let goals = 2
static let questsPerGoal = 3
}
/// (CLAUDE.md §7.2)
nonisolated enum PremiumFeature {
/// ··
case unlimitedItems
///
case homeWidgets
///
case lockScreenWidgets
/// +
case watchApp
/// (App Intents)
case siriShortcuts
/// iCloud
case cloudSync
/// iPad ()
case diary
}
// MARK: - (StoreKit )
/// .
/// StoreKit 2 `Transaction.currentEntitlements`
/// `Transaction.updates` entitlementUpdates() .
nonisolated protocol EntitlementProvider: Sendable {
///
func fetchIsEntitled() async -> Bool
/// ·
func entitlementUpdates() -> AsyncStream<Bool>
}
/// :
nonisolated struct MockEntitlementProvider: EntitlementProvider {
func fetchIsEntitled() async -> Bool {
PremiumGate.isPremium
}
func entitlementUpdates() -> AsyncStream<Bool> {
// Mock ( PremiumManager )
AsyncStream { $0.finish() }
}
}
// MARK: -
/// ··App Intents PremiumManager
/// App Group
nonisolated enum PremiumGate {
/// UserDefaults.standard ( )
static let cacheKey = "settings.isPremium"
static var isPremium: Bool {
AppGroup.defaults.bool(forKey: cacheKey)
}
static func isUnlocked(_ feature: PremiumFeature) -> Bool {
// .
isPremium
}
static func cache(_ value: Bool) {
AppGroup.defaults.set(value, forKey: cacheKey)
}
}
// MARK: -
/// .
/// `PremiumManager.shared.isPremium` canAdd~ / isUnlocked .
@MainActor
@Observable
final class PremiumManager {
static private(set) var shared = PremiumManager()
/// (StoreKitEntitlementProvider) .
/// (·) bootstrap Mock + PremiumGate .
static func bootstrap(provider: any EntitlementProvider) {
shared = PremiumManager(provider: provider)
}
private(set) var isPremium: Bool
@ObservationIgnored private let provider: any EntitlementProvider
#if DEBUG
/// (-premium · )
/// StoreKit
static let mockActiveKey = "debug.premiumMockActive"
private var isMockActive: Bool {
UserDefaults.standard.object(forKey: "premium") != nil
|| AppGroup.defaults.bool(forKey: Self.mockActiveKey)
}
#endif
init(provider: any EntitlementProvider = MockEntitlementProvider()) {
self.provider = provider
Self.migrateLegacyFlagIfNeeded()
#if DEBUG
// : -premium YES/NO
if UserDefaults.standard.object(forKey: "premium") != nil {
PremiumGate.cache(UserDefaults.standard.bool(forKey: "premium"))
}
#endif
self.isPremium = PremiumGate.isPremium
Task { [weak self] in
guard let self else { return }
self.applyFromProvider(await self.provider.fetchIsEntitled())
for await value in self.provider.entitlementUpdates() {
self.applyFromProvider(value)
}
}
}
// MARK:
func isUnlocked(_ feature: PremiumFeature) -> Bool {
// .
isPremium
}
func canAddAction(currentCount: Int) -> Bool {
isUnlocked(.unlimitedItems) || currentCount < FreeLimits.actions
}
func canAddGoal(currentCount: Int) -> Bool {
isUnlocked(.unlimitedItems) || currentCount < FreeLimits.goals
}
func canAddQuest(currentCount: Int) -> Bool {
isUnlocked(.unlimitedItems) || currentCount < FreeLimits.questsPerGoal
}
// MARK:
/// / ( )
func refresh() async {
applyFromProvider(await provider.fetchIsEntitled())
}
#if DEBUG
/// (DEBUG ).
func setMockPremium(_ value: Bool) {
AppGroup.defaults.set(true, forKey: Self.mockActiveKey)
apply(value)
}
/// (DEBUG ) StoreKit .
/// · (PremiumStore) .
func clearMockOverride() async {
AppGroup.defaults.removeObject(forKey: Self.mockActiveKey)
await refresh()
}
#endif
/// (StoreKit) . DEBUG
private func applyFromProvider(_ value: Bool) {
#if DEBUG
guard !isMockActive else { return }
#endif
apply(value)
}
private func apply(_ value: Bool) {
isPremium = value
PremiumGate.cache(value)
}
/// UserDefaults.standard settings.isPremium App Group 1
private static func migrateLegacyFlagIfNeeded() {
let standard = UserDefaults.standard
guard AppGroup.defaults.object(forKey: PremiumGate.cacheKey) == nil,
let legacy = standard.object(forKey: PremiumGate.cacheKey) as? Bool else { return }
PremiumGate.cache(legacy)
}
}