feat(premium): StoreKit 2 결제 레이어 구현 (실결제 연결 직전 단계)
- StoreKitEntitlementProvider: Transaction.currentEntitlements 기반 실권한 조회 + Transaction.updates 이벤트 반영. 앱 시작 시 PremiumManager.bootstrap 으로 주입 (확장 타깃은 기존 PremiumGate 캐시 그대로) - PremiumStore: 상품 로드/구매/복원 (월·년 구독 + 평생 이용권 3종) - PremiumView: 실구매 UI (상품 목록·가격·구매 복원, 미로드 시 재시도 폴백) - DEBUG 강제 프리미엄(-premium, 테스트 토글) 중에는 StoreKit 결과가 덮어쓰지 않도록 보호 - HaruDanim.storekit 로컬 테스트 구성(월 5,000 / 년 50,000 / 일회성 80,000원, ko·en·ja 현지화) + 메인 스킴에 연결 → Xcode 실행으로 결제 흐름 테스트 가능 - 남은 것: App Store Connect에 productID 3종 등록만 하면 실결제 연결 완료 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
parent
0cdb566ad5
commit
1e62c082d3
132
myApp/HaruDanim/HaruDanim.storekit
Normal file
132
myApp/HaruDanim/HaruDanim.storekit
Normal file
@ -0,0 +1,132 @@
|
||||
{
|
||||
"identifier" : "F1A2B3C4-0001-4A2B-9C3D-5E6F7A8B9C0D",
|
||||
"nonRenewingSubscriptions" : [
|
||||
|
||||
],
|
||||
"products" : [
|
||||
{
|
||||
"displayPrice" : "80000",
|
||||
"familyShareable" : false,
|
||||
"internalID" : "F1A2B3C4-0002-4A2B-9C3D-5E6F7A8B9C0D",
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "한 번 결제로 모든 프리미엄 기능을 평생 사용해요",
|
||||
"displayName" : "평생 이용권",
|
||||
"locale" : "ko"
|
||||
},
|
||||
{
|
||||
"description" : "Pay once and use all premium features forever",
|
||||
"displayName" : "Lifetime",
|
||||
"locale" : "en"
|
||||
},
|
||||
{
|
||||
"description" : "1回の購入ですべてのプレミアム機能をずっと利用できます",
|
||||
"displayName" : "買い切り",
|
||||
"locale" : "ja"
|
||||
}
|
||||
],
|
||||
"productID" : "com.yechan.HaruDanim.premium.lifetime",
|
||||
"referenceName" : "Premium Lifetime",
|
||||
"type" : "NonConsumable"
|
||||
}
|
||||
],
|
||||
"settings" : {
|
||||
"_failTransactionsEnabled" : false,
|
||||
"_locale" : "ko_KR",
|
||||
"_storefront" : "KOR",
|
||||
"_storeKitErrors" : [
|
||||
|
||||
]
|
||||
},
|
||||
"subscriptionGroups" : [
|
||||
{
|
||||
"id" : "F1A2B3C4-0003-4A2B-9C3D-5E6F7A8B9C0D",
|
||||
"localizations" : [
|
||||
|
||||
],
|
||||
"name" : "HaruDanim Premium",
|
||||
"subscriptions" : [
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "5000",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 1,
|
||||
"internalID" : "F1A2B3C4-0004-4A2B-9C3D-5E6F7A8B9C0D",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "모든 프리미엄 기능을 월 단위로 구독해요",
|
||||
"displayName" : "월 구독",
|
||||
"locale" : "ko"
|
||||
},
|
||||
{
|
||||
"description" : "Subscribe to all premium features monthly",
|
||||
"displayName" : "Monthly",
|
||||
"locale" : "en"
|
||||
},
|
||||
{
|
||||
"description" : "すべてのプレミアム機能を月単位で購読します",
|
||||
"displayName" : "月額プラン",
|
||||
"locale" : "ja"
|
||||
}
|
||||
],
|
||||
"productID" : "com.yechan.HaruDanim.premium.monthly",
|
||||
"recurringSubscriptionPeriod" : "P1M",
|
||||
"referenceName" : "Premium Monthly",
|
||||
"subscriptionGroupID" : "F1A2B3C4-0003-4A2B-9C3D-5E6F7A8B9C0D",
|
||||
"type" : "RecurringSubscription",
|
||||
"winbackOffers" : [
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "50000",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 1,
|
||||
"internalID" : "F1A2B3C4-0005-4A2B-9C3D-5E6F7A8B9C0D",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "모든 프리미엄 기능을 년 단위로 구독해요",
|
||||
"displayName" : "년 구독",
|
||||
"locale" : "ko"
|
||||
},
|
||||
{
|
||||
"description" : "Subscribe to all premium features yearly",
|
||||
"displayName" : "Yearly",
|
||||
"locale" : "en"
|
||||
},
|
||||
{
|
||||
"description" : "すべてのプレミアム機能を年単位で購読します",
|
||||
"displayName" : "年額プラン",
|
||||
"locale" : "ja"
|
||||
}
|
||||
],
|
||||
"productID" : "com.yechan.HaruDanim.premium.yearly",
|
||||
"recurringSubscriptionPeriod" : "P1Y",
|
||||
"referenceName" : "Premium Yearly",
|
||||
"subscriptionGroupID" : "F1A2B3C4-0003-4A2B-9C3D-5E6F7A8B9C0D",
|
||||
"type" : "RecurringSubscription",
|
||||
"winbackOffers" : [
|
||||
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"version" : {
|
||||
"major" : 4,
|
||||
"minor" : 0
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2660"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "725A9E832FFCC65000846FFB"
|
||||
BuildableName = "Haru_Danim.app"
|
||||
BlueprintName = "Haru_Danim"
|
||||
ReferencedContainer = "container:Haru_Danim.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
queueDebuggingEnableBacktraceRecording = "Yes">
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../HaruDanim.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "725A9E832FFCC65000846FFB"
|
||||
BuildableName = "Haru_Danim.app"
|
||||
BlueprintName = "Haru_Danim"
|
||||
ReferencedContainer = "container:Haru_Danim.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "725A9E832FFCC65000846FFB"
|
||||
BuildableName = "Haru_Danim.app"
|
||||
BlueprintName = "Haru_Danim"
|
||||
ReferencedContainer = "container:Haru_Danim.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@ -17,8 +17,11 @@ struct ContentView: View {
|
||||
ZStack {
|
||||
#if DEBUG
|
||||
// 검증용: -widgetPreview YES(홈 위젯) / lock(잠금화면 위젯) → 위젯 미리보기 화면
|
||||
// -premiumPreview YES → 프리미엄 결제 화면 직접 표시
|
||||
if let mode = UserDefaults.standard.string(forKey: "widgetPreview") {
|
||||
WidgetPreviewScreen(showLock: mode == "lock")
|
||||
} else if UserDefaults.standard.bool(forKey: "premiumPreview") {
|
||||
NavigationStack { PremiumView() }
|
||||
} else {
|
||||
MainTabView()
|
||||
}
|
||||
|
||||
129
myApp/HaruDanim/IOS/Core/Store.swift
Normal file
129
myApp/HaruDanim/IOS/Core/Store.swift
Normal file
@ -0,0 +1,129 @@
|
||||
//
|
||||
// Store.swift
|
||||
// Haru_Danim
|
||||
//
|
||||
// StoreKit 2 결제 레이어 (CLAUDE.md §1 수익 모델, §7.2)
|
||||
// - StoreKitEntitlementProvider: Transaction.currentEntitlements 기반 실권한 조회.
|
||||
// 앱 시작 시 PremiumManager.bootstrap으로 주입된다.
|
||||
// - PremiumStore: 상품 로드 / 구매 / 복원. PremiumView가 사용.
|
||||
// - App Store Connect에 아래 productID 3종을 등록하면 실결제가 연결된다.
|
||||
// 로컬 테스트: 프로젝트 루트의 HaruDanim.storekit을 스킴 옵션에서 선택.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import StoreKit
|
||||
|
||||
// MARK: - 상품 정의
|
||||
|
||||
nonisolated enum PremiumProducts {
|
||||
/// 월 구독 (임시가 월 5,000원)
|
||||
static let monthly = "com.yechan.HaruDanim.premium.monthly"
|
||||
/// 년 구독 (임시가 년 50,000원)
|
||||
static let yearly = "com.yechan.HaruDanim.premium.yearly"
|
||||
/// 일회성 평생 이용권 (임시가 80,000원)
|
||||
static let lifetime = "com.yechan.HaruDanim.premium.lifetime"
|
||||
|
||||
static let all: Set<String> = [monthly, yearly, lifetime]
|
||||
|
||||
/// 결제 화면 표시 순서
|
||||
static func sortOrder(_ id: String) -> Int {
|
||||
switch id {
|
||||
case monthly: return 0
|
||||
case yearly: return 1
|
||||
default: return 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 실권한 제공자 (PremiumManager에 주입)
|
||||
|
||||
/// StoreKit 2의 현재 구매 내역으로 프리미엄 여부를 판단하는 EntitlementProvider 구현체
|
||||
nonisolated struct StoreKitEntitlementProvider: EntitlementProvider {
|
||||
func fetchIsEntitled() async -> Bool {
|
||||
for await result in Transaction.currentEntitlements {
|
||||
if case .verified(let transaction) = result,
|
||||
PremiumProducts.all.contains(transaction.productID),
|
||||
transaction.revocationDate == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func entitlementUpdates() -> AsyncStream<Bool> {
|
||||
AsyncStream { continuation in
|
||||
let task = Task {
|
||||
// 구매·갱신·환불 이벤트마다 권한을 재계산해서 흘려보낸다
|
||||
for await update in Transaction.updates {
|
||||
if case .verified(let transaction) = update {
|
||||
await transaction.finish()
|
||||
}
|
||||
continuation.yield(await fetchIsEntitled())
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 구매 스토어 (결제 화면용)
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PremiumStore {
|
||||
static let shared = PremiumStore()
|
||||
|
||||
private(set) var products: [Product] = []
|
||||
private(set) var isLoading = false
|
||||
private(set) var isPurchasing = false
|
||||
/// 사용자에게 보여줄 마지막 오류 메시지 (nil이면 정상)
|
||||
private(set) var lastErrorMessage: String?
|
||||
|
||||
/// App Store에서 상품 정보 로드 (결제 화면 진입 시 호출)
|
||||
func loadProducts() async {
|
||||
guard products.isEmpty, !isLoading else { return }
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let fetched = try await Product.products(for: PremiumProducts.all)
|
||||
products = fetched.sorted {
|
||||
PremiumProducts.sortOrder($0.id) < PremiumProducts.sortOrder($1.id)
|
||||
}
|
||||
lastErrorMessage = nil
|
||||
} catch {
|
||||
lastErrorMessage = String(localized: "상품 정보를 불러오지 못했어요. 네트워크를 확인해 주세요.")
|
||||
}
|
||||
}
|
||||
|
||||
/// 구매 실행. 성공 시 PremiumManager가 갱신되어 즉시 잠금 해제된다
|
||||
func purchase(_ product: Product) async {
|
||||
guard !isPurchasing else { return }
|
||||
isPurchasing = true
|
||||
defer { isPurchasing = false }
|
||||
do {
|
||||
let result = try await product.purchase()
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
if case .verified(let transaction) = verification {
|
||||
await transaction.finish()
|
||||
}
|
||||
await PremiumManager.shared.refresh()
|
||||
lastErrorMessage = nil
|
||||
case .userCancelled, .pending:
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
lastErrorMessage = String(localized: "구매를 완료하지 못했어요. 잠시 후 다시 시도해 주세요.")
|
||||
}
|
||||
}
|
||||
|
||||
/// 구매 복원 (기기 변경·재설치 시)
|
||||
func restore() async {
|
||||
try? await AppStore.sync()
|
||||
await PremiumManager.shared.refresh()
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,8 @@ struct Haru_DanimApp: App {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
init() {
|
||||
// 실결제 권한 제공자 주입 (다른 무엇보다 먼저 — shared 최초 접근 전에 교체)
|
||||
PremiumManager.bootstrap(provider: StoreKitEntitlementProvider())
|
||||
SettingsMigration.runIfNeeded()
|
||||
container = DataStore.shared
|
||||
DataStore.ensureUniqueEntityIDs(context: container.mainContext)
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import StoreKit
|
||||
import WidgetKit
|
||||
|
||||
struct SettingsView: View {
|
||||
@ -246,6 +247,7 @@ struct SettingsView: View {
|
||||
|
||||
struct PremiumView: View {
|
||||
private let premium = PremiumManager.shared
|
||||
private let store = PremiumStore.shared
|
||||
@AppStorage(DataStore.cloudSyncKey, store: AppGroup.defaults) private var cloudSync = false
|
||||
|
||||
var body: some View {
|
||||
@ -269,16 +271,7 @@ struct PremiumView: View {
|
||||
Text("iCloud로 아이패드 등 다른 기기와 데이터가 동기화돼요. 변경 사항은 앱을 완전히 종료했다가 다시 실행하면 적용돼요.")
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
Button {
|
||||
// StoreKit 결제는 Apple Developer Program 가입 후 연결 (CLAUDE.md §2.3)
|
||||
} label: {
|
||||
Label("프리미엄 구매 (준비 중)", systemImage: "cart.fill")
|
||||
}
|
||||
.disabled(true)
|
||||
} footer: {
|
||||
Text("결제 기능은 App Store 배포 준비와 함께 제공될 예정이에요.")
|
||||
}
|
||||
purchaseSection
|
||||
}
|
||||
#if DEBUG
|
||||
Section("개발용") {
|
||||
@ -294,6 +287,80 @@ struct PremiumView: View {
|
||||
.navigationTitle("프리미엄")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
.task {
|
||||
await store.loadProducts()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 구매 (StoreKit 2)
|
||||
|
||||
@ViewBuilder
|
||||
private var purchaseSection: some View {
|
||||
Section {
|
||||
if store.products.isEmpty {
|
||||
if store.isLoading {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("상품 정보를 불러오는 중...")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
// App Store Connect에 상품 등록 전이거나 네트워크 문제
|
||||
Label("상품 정보를 불러올 수 없어요", systemImage: "wifi.exclamationmark")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("다시 시도") {
|
||||
Task { await store.loadProducts() }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ForEach(store.products) { product in
|
||||
productRow(product)
|
||||
}
|
||||
}
|
||||
Button {
|
||||
Task { await store.restore() }
|
||||
} label: {
|
||||
Text("구매 복원")
|
||||
.font(.callout)
|
||||
}
|
||||
.disabled(store.isPurchasing)
|
||||
} header: {
|
||||
Text("프리미엄 구매")
|
||||
} footer: {
|
||||
if let message = store.lastErrorMessage {
|
||||
Text(message).foregroundStyle(.red)
|
||||
} else {
|
||||
Text("구독은 언제든 해지할 수 있고, 평생 이용권은 한 번 결제로 모든 기능을 계속 사용해요.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func productRow(_ product: Product) -> some View {
|
||||
Button {
|
||||
Task { await store.purchase(product) }
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(product.displayName)
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
if !product.description.isEmpty {
|
||||
Text(product.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text(product.displayPrice)
|
||||
.font(.body.weight(.bold))
|
||||
.foregroundStyle(AppTheme.green)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(store.isPurchasing)
|
||||
}
|
||||
|
||||
private func featureRow(_ symbol: String, _ title: String, _ detail: String) -> some View {
|
||||
|
||||
@ -102,11 +102,27 @@ nonisolated enum PremiumGate {
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PremiumManager {
|
||||
static let shared = 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()
|
||||
@ -119,9 +135,9 @@ final class PremiumManager {
|
||||
self.isPremium = PremiumGate.isPremium
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
self.apply(await self.provider.fetchIsEntitled())
|
||||
self.applyFromProvider(await self.provider.fetchIsEntitled())
|
||||
for await value in self.provider.entitlementUpdates() {
|
||||
self.apply(value)
|
||||
self.applyFromProvider(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -147,13 +163,24 @@ final class PremiumManager {
|
||||
|
||||
// MARK: 상태 갱신
|
||||
|
||||
/// 결제/복원 후 권한 재조회 (StoreKit 연동 시 구매 완료 지점에서 호출)
|
||||
/// 결제/복원 후 권한 재조회 (구매 완료 지점에서 호출)
|
||||
func refresh() async {
|
||||
apply(await provider.fetchIsEntitled())
|
||||
applyFromProvider(await provider.fetchIsEntitled())
|
||||
}
|
||||
|
||||
/// 설정 탭 테스트 토글용. StoreKit 도입 후에는 DEBUG 전용으로 남긴다
|
||||
#if DEBUG
|
||||
/// 설정 탭 테스트 토글용 (DEBUG 전용). 켜는 순간 실권한 조회를 무시하는 모드가 된다
|
||||
func setMockPremium(_ value: Bool) {
|
||||
AppGroup.defaults.set(true, forKey: Self.mockActiveKey)
|
||||
apply(value)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 권한 제공자(StoreKit) 결과 반영. DEBUG 강제 프리미엄 중에는 무시
|
||||
private func applyFromProvider(_ value: Bool) {
|
||||
#if DEBUG
|
||||
guard !isMockActive else { return }
|
||||
#endif
|
||||
apply(value)
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user