- '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
209 lines
7.5 KiB
Swift
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)
|
|
}
|
|
}
|