diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml new file mode 100644 index 0000000..150bede --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,24 @@ +name: CI +on: + push: + tags: + - v* + +jobs: + tags: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + - name: Extract version from tag + id: extract_version + run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + - name: Create Tag + id: create_tag + uses: jaywcjlove/create-tag-action@main + with: + version: ${{ env.VERSION }} + release: true + body: | + ## Changes in version ${{ env.VERSION }} \ No newline at end of file diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj new file mode 100644 index 0000000..bef1a32 --- /dev/null +++ b/Example/Example.xcodeproj/project.pbxproj @@ -0,0 +1,573 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + E0EB56F52F0145AE001F6F30 /* StoreKitHelper in Frameworks */ = {isa = PBXBuildFile; productRef = E0EB56F42F0145AE001F6F30 /* StoreKitHelper */; }; + E0EB56F82F0145C5001F6F30 /* StoreKitHelper in Frameworks */ = {isa = PBXBuildFile; productRef = E0EB56F72F0145C5001F6F30 /* StoreKitHelper */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + E0EB56D72F01455C001F6F30 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E0EB56C12F014559001F6F30 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E0EB56C82F014559001F6F30; + remoteInfo = Example; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + E0EB56C92F014559001F6F30 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + E0EB56D62F01455C001F6F30 /* ExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + E0EB56FC2F014628001F6F30 /* Exceptions for "ExampleTests" folder in "Example" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Configuration.storekit, + ); + target = E0EB56C82F014559001F6F30 /* Example */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + E0EB56CB2F014559001F6F30 /* Example */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Example; + sourceTree = ""; + }; + E0EB56D92F01455C001F6F30 /* ExampleTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + E0EB56FC2F014628001F6F30 /* Exceptions for "ExampleTests" folder in "Example" target */, + ); + path = ExampleTests; + sourceTree = ""; + }; + E0EB56E32F01455C001F6F30 /* ExampleUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ExampleUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + E0EB56C62F014559001F6F30 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E0EB56F82F0145C5001F6F30 /* StoreKitHelper in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E0EB56D32F01455C001F6F30 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E0EB56F52F0145AE001F6F30 /* StoreKitHelper in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + E0EB56C02F014559001F6F30 = { + isa = PBXGroup; + children = ( + E0EB56CB2F014559001F6F30 /* Example */, + E0EB56D92F01455C001F6F30 /* ExampleTests */, + E0EB56E32F01455C001F6F30 /* ExampleUITests */, + E0EB56F62F0145C5001F6F30 /* Frameworks */, + E0EB56CA2F014559001F6F30 /* Products */, + ); + sourceTree = ""; + }; + E0EB56CA2F014559001F6F30 /* Products */ = { + isa = PBXGroup; + children = ( + E0EB56C92F014559001F6F30 /* Example.app */, + E0EB56D62F01455C001F6F30 /* ExampleTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + E0EB56F62F0145C5001F6F30 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + E0EB56C82F014559001F6F30 /* Example */ = { + isa = PBXNativeTarget; + buildConfigurationList = E0EB56EA2F01455C001F6F30 /* Build configuration list for PBXNativeTarget "Example" */; + buildPhases = ( + E0EB56C52F014559001F6F30 /* Sources */, + E0EB56C62F014559001F6F30 /* Frameworks */, + E0EB56C72F014559001F6F30 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + E0EB56CB2F014559001F6F30 /* Example */, + ); + name = Example; + packageProductDependencies = ( + E0EB56F72F0145C5001F6F30 /* StoreKitHelper */, + ); + productName = Example; + productReference = E0EB56C92F014559001F6F30 /* Example.app */; + productType = "com.apple.product-type.application"; + }; + E0EB56D52F01455C001F6F30 /* ExampleTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = E0EB56ED2F01455C001F6F30 /* Build configuration list for PBXNativeTarget "ExampleTests" */; + buildPhases = ( + E0EB56D22F01455C001F6F30 /* Sources */, + E0EB56D32F01455C001F6F30 /* Frameworks */, + E0EB56D42F01455C001F6F30 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E0EB56D82F01455C001F6F30 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + E0EB56D92F01455C001F6F30 /* ExampleTests */, + ); + name = ExampleTests; + packageProductDependencies = ( + E0EB56F42F0145AE001F6F30 /* StoreKitHelper */, + ); + productName = ExampleTests; + productReference = E0EB56D62F01455C001F6F30 /* ExampleTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + E0EB56C12F014559001F6F30 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + E0EB56C82F014559001F6F30 = { + CreatedOnToolsVersion = 26.0; + }; + E0EB56D52F01455C001F6F30 = { + CreatedOnToolsVersion = 26.0; + TestTargetID = E0EB56C82F014559001F6F30; + }; + }; + }; + buildConfigurationList = E0EB56C42F014559001F6F30 /* Build configuration list for PBXProject "Example" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + "zh-Hans", + ); + mainGroup = E0EB56C02F014559001F6F30; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + E0EB56F32F0145AE001F6F30 /* XCLocalSwiftPackageReference "../../StoreKitHelper" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = E0EB56CA2F014559001F6F30 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + E0EB56C82F014559001F6F30 /* Example */, + E0EB56D52F01455C001F6F30 /* ExampleTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + E0EB56C72F014559001F6F30 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E0EB56D42F01455C001F6F30 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + E0EB56C52F014559001F6F30 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E0EB56D22F01455C001F6F30 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + E0EB56D82F01455C001F6F30 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E0EB56C82F014559001F6F30 /* Example */; + targetProxy = E0EB56D72F01455C001F6F30 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + E0EB56E82F01455C001F6F30 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = GR99S2ZJZQ; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + E0EB56E92F01455C001F6F30 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = GR99S2ZJZQ; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + }; + name = Release; + }; + E0EB56EB2F01455C001F6F30 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = GR99S2ZJZQ; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; + ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.wangchujiang.Example; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; + E0EB56EC2F01455C001F6F30 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = GR99S2ZJZQ; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; + ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.wangchujiang.Example; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; + E0EB56EE2F01455C001F6F30 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = GR99S2ZJZQ; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.wangchujiang.ExampleTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Example"; + XROS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; + E0EB56EF2F01455C001F6F30 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = GR99S2ZJZQ; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.wangchujiang.ExampleTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Example"; + XROS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + E0EB56C42F014559001F6F30 /* Build configuration list for PBXProject "Example" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E0EB56E82F01455C001F6F30 /* Debug */, + E0EB56E92F01455C001F6F30 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E0EB56EA2F01455C001F6F30 /* Build configuration list for PBXNativeTarget "Example" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E0EB56EB2F01455C001F6F30 /* Debug */, + E0EB56EC2F01455C001F6F30 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E0EB56ED2F01455C001F6F30 /* Build configuration list for PBXNativeTarget "ExampleTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E0EB56EE2F01455C001F6F30 /* Debug */, + E0EB56EF2F01455C001F6F30 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + E0EB56F32F0145AE001F6F30 /* XCLocalSwiftPackageReference "../../StoreKitHelper" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../../StoreKitHelper; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + E0EB56F42F0145AE001F6F30 /* StoreKitHelper */ = { + isa = XCSwiftPackageProductDependency; + productName = StoreKitHelper; + }; + E0EB56F72F0145C5001F6F30 /* StoreKitHelper */ = { + isa = XCSwiftPackageProductDependency; + package = E0EB56F32F0145AE001F6F30 /* XCLocalSwiftPackageReference "../../StoreKitHelper" */; + productName = StoreKitHelper; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = E0EB56C12F014559001F6F30 /* Project object */; +} diff --git a/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme b/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme new file mode 100644 index 0000000..5863225 --- /dev/null +++ b/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json b/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..ffdfe15 --- /dev/null +++ b/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,85 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/Example/Assets.xcassets/Contents.json b/Example/Example/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Example/Example/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/Example/ContentView.swift b/Example/Example/ContentView.swift new file mode 100644 index 0000000..fa1e33d --- /dev/null +++ b/Example/Example/ContentView.swift @@ -0,0 +1,105 @@ +// +// ContentView.swift +// Example +// +// Created by wong on 12/28/25. +// + +import SwiftUI +import StoreKit +import StoreKitHelper + +struct ContentView: View { + @EnvironmentObject var store: StoreContext +// @Environment(\.locale) var locale + var body: some View { + if store.hasNotPurchased == true { + PurchasePopupButton() + .sheet(isPresented: $store.isShowingPurchasePopup) { + PurchaseContent() + } + } + let locale: Locale = Locale(identifier: Locale.preferredLanguages.first ?? "en") + PurchaseContent() + .environment(\.locale, .init(identifier: locale.identifier)) +// PurchaseExample() + } +} + +struct PurchaseContent: View { + @EnvironmentObject var store: StoreContext + let locale: Locale = Locale(identifier: Locale.preferredLanguages.first ?? "en") + var body: some View { + StoreKitHelperView() +// StoreKitHelperSelectionView() + .environment(\.locale, .init(identifier: locale.identifier)) + .environment(\.pricingContent, { AnyView(PricingContent()) }) + .environment(\.popupDismissHandle, { + store.isShowingPurchasePopup = false + }) + .environment(\.termsOfServiceHandle, { + // Action triggered when the [Terms of Service] button is clicked + print("Action triggered when the [Terms of Service] button is clicked") + }) + .environment(\.privacyPolicyHandle, { + // Action triggered when the [Privacy Policy] button is clicked + print("Action triggered when the [Privacy Policy] button is clicked") + }) + .frame(maxWidth: 300) + .frame(minWidth: 260) + } +} + + +struct PricingContent: View { + var body: some View { + VStack { + Text("Unlock all Features").font(.system(size: 18, weight: .bold)) + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Free").frame(width: 30, alignment: .center) + Text("Pro") + } + .font(.system(size: 12)) + Divider() + FeaturesCheckmarkRow() { + Text("Move Mouse with Keyboard") + } + FeaturesCheckmarkRow() { + Text("Grid-Based Positioning") + } + FeaturesCheckmarkRow(features: .vip) { + Text("App Navigation Configuration") + } + FeaturesCheckmarkRow(features: .vip) { + Text("Keyboard-Mouse Mode Notification Settings") + } + } + .padding(.horizontal) + .padding(.top, 6) + } + .padding(.bottom) + } +} + +struct FeaturesCheckmarkRow: View { + enum Feature { + case vip, free + } + var features: Feature = .free + var label: () -> Lablel + var body: some View { + HStack(alignment: .top) { + HStack { + Image(systemName: iconName).foregroundStyle(features == .free ? Color.green : Color.red) + } + .frame(width: 30, alignment: .center) + Image(systemName: "checkmark.circle.fill").foregroundStyle(Color.green) + label().font(.system(size: 12, weight: .light)) + } + .frame(alignment: .topLeading) + } + var iconName: String { + features == .free ? "checkmark.circle.fill" : "xmark" + } +} diff --git a/Example/Example/ExampleApp.swift b/Example/Example/ExampleApp.swift new file mode 100644 index 0000000..163877c --- /dev/null +++ b/Example/Example/ExampleApp.swift @@ -0,0 +1,25 @@ +// +// ExampleApp.swift +// Example +// +// Created by wong on 12/28/25. +// + +import SwiftUI +import StoreKitHelper + +enum AppProduct: String, InAppProduct { + case lifetime = "test.lifetime" + case monthly = "test.monthly" + var id: String { rawValue } +} + +@main +struct ExampleApp: App { + @StateObject var store = StoreContext(products: AppProduct.allCases) + var body: some Scene { + WindowGroup { + ContentView().environmentObject(store) + } + } +} diff --git a/Example/Example/Localizable.xcstrings b/Example/Example/Localizable.xcstrings new file mode 100644 index 0000000..7abcb5e --- /dev/null +++ b/Example/Example/Localizable.xcstrings @@ -0,0 +1,75 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "✅ 完整功能已解锁" : { + + }, + "🔒 受限功能" : { + + }, + "App Navigation Configuration" : { + + }, + "Free" : { + + }, + "Grid-Based Positioning" : { + + }, + "Keyboard-Mouse Mode Notification Settings" : { + + }, + "Move Mouse with Keyboard" : { + + }, + "Pro" : { + + }, + "Unlock all Features" : { + + }, + "加载产品中..." : { + + }, + "可购买产品" : { + + }, + "已购买" : { + + }, + "已购买产品: %@" : { + + }, + "应用功能" : { + + }, + "恢复购买" : { + + }, + "感谢您的支持!您可以使用所有功能" : { + + }, + "暂无可购买的产品" : { + + }, + "未购买" : { + + }, + "确定" : { + + }, + "请购买产品以解锁完整功能" : { + + }, + "购买" : { + + }, + "购买状态" : { + + }, + "错误" : { + + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/Example/Example/Purchase+Example.swift b/Example/Example/Purchase+Example.swift new file mode 100644 index 0000000..961d6a4 --- /dev/null +++ b/Example/Example/Purchase+Example.swift @@ -0,0 +1,177 @@ +// +// Purchase+Example.swift +// Example +// +// Created by wong on 12/28/25. +// + +import SwiftUI +import StoreKit +import StoreKitHelper + +struct PurchaseExample: View { + @EnvironmentObject var store: StoreContext +// @Environment(\.locale) var locale + var body: some View { + let locale: Locale = Locale(identifier: Locale.preferredLanguages.first ?? "en") + VStack(spacing: 20) { + // 状态显示 + statusSection + Divider() + + // 产品列表 + if store.isLoading { + ProgressView("加载产品中...") + } else { + productsSection + } + + Divider() + + // 功能区域 + featureSection + + Spacer() + + // 恢复购买按钮 + Button("恢复购买") { + Task { + await store.restorePurchases() + } + } + .buttonStyle(.bordered) + } + .padding() + .alert("错误", isPresented: .constant(store.storeError != nil)) { + Button("确定") { + // 清除错误信息的逻辑可以在这里添加 + } + } message: { + Text(store.storeError?.description(locale: locale) ?? "") + } + } + // MARK: - 状态显示区域 + private var statusSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("购买状态") + .font(.headline) + HStack { + Image(systemName: store.hasPurchased ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundColor(store.hasPurchased ? .green : .red) + + Text(store.hasPurchased ? "已购买" : "未购买") + .font(.subheadline) + + Spacer() + } + + if !store.purchasedProductIDs.isEmpty { + Text("已购买产品: \(Array(store.purchasedProductIDs).joined(separator: ", "))") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding() + .cornerRadius(8) + } + + // MARK: - 产品列表区域 + private var productsSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("可购买产品") + .font(.headline) + + ForEach(store.products, id: \.id) { product in + ProductRow(product: product) + } + + if store.products.isEmpty { + Text("暂无可购买的产品") + .foregroundColor(.secondary) + .italic() + } + } + } + + // MARK: - 功能区域 + private var featureSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("应用功能") + .font(.headline) + + if store.hasNotPurchased { + VStack(alignment: .leading, spacing: 8) { + Text("🔒 受限功能") + .font(.subheadline) + .foregroundColor(.orange) + + Text("请购买产品以解锁完整功能") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .background(Color.orange.opacity(0.1)) + .cornerRadius(8) + } else { + VStack(alignment: .leading, spacing: 8) { + Text("✅ 完整功能已解锁") + .font(.subheadline) + .foregroundColor(.green) + + Text("感谢您的支持!您可以使用所有功能") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .background(Color.green.opacity(0.1)) + .cornerRadius(8) + } + } + } +} + + +// MARK: - 产品行视图 +struct ProductRow: View { + let product: Product + @EnvironmentObject var store: StoreContext + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(product.displayName) + .font(.subheadline) + .fontWeight(.medium) + + Text(product.description) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + Text(product.displayPrice) + .font(.subheadline) + .fontWeight(.semibold) + + if store.isPurchased(product.id) { + Text("已购买") + .font(.caption) + .foregroundColor(.green) + .fontWeight(.medium) + } else { + Button("购买") { + Task { + await store.purchase(product) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + } + .padding() + .cornerRadius(8) + } +} diff --git a/Example/ExampleTests/Configuration.storekit b/Example/ExampleTests/Configuration.storekit new file mode 100644 index 0000000..2cc3a6d --- /dev/null +++ b/Example/ExampleTests/Configuration.storekit @@ -0,0 +1,95 @@ +{ + "appPolicies" : { + "eula" : "", + "policies" : [ + { + "locale" : "en_US", + "policyText" : "", + "policyURL" : "" + } + ] + }, + "identifier" : "8F3F5875", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "0.99", + "familyShareable" : false, + "internalID" : "84CFE130", + "localizations" : [ + { + "description" : "Lifetime to unlock all features", + "displayName" : "All Access Lifetime", + "locale" : "en_US" + } + ], + "productID" : "test.lifetime", + "referenceName" : "Example - Lifetime", + "type" : "NonConsumable" + } + ], + "settings" : { + "_askToBuyEnabled" : false, + "_billingGracePeriodEnabled" : false, + "_billingIssuesEnabled" : false, + "_disableDialogs" : false, + "_failTransactionsEnabled" : false, + "_locale" : "en_US", + "_renewalBillingIssuesEnabled" : false, + "_storefront" : "USA", + "_storeKitErrors" : [ + + ], + "_timeRate" : 0 + }, + "subscriptionGroups" : [ + { + "id" : "DD979BE3", + "localizations" : [ + + ], + "name" : "Example Pro", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "0.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "0C48BA22", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "Subscribe monthly to unlock all features", + "displayName" : "All Access Monthly", + "locale" : "en_US" + }, + { + "description" : "按月订阅付费解锁所有功能", + "displayName" : "全功能包月", + "locale" : "zh_Hans" + } + ], + "productID" : "test.monthly", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Example - All Access Monthly", + "subscriptionGroupID" : "DD979BE3", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + } + ] + } + ], + "version" : { + "major" : 4, + "minor" : 0 + } +} diff --git a/Example/ExampleTests/ExampleTests.swift b/Example/ExampleTests/ExampleTests.swift new file mode 100644 index 0000000..01061be --- /dev/null +++ b/Example/ExampleTests/ExampleTests.swift @@ -0,0 +1,161 @@ +// +// ExampleTests.swift +// ExampleTests +// +// Created by wong on 12/28/25. +// + +import Testing +import StoreKit +import StoreKitTest + +@testable import StoreKitHelper + +enum AppProduct: String, InAppProduct { + case lifetime = "test.lifetime" + case monthly = "test.monthly" + var id: String { rawValue } +} + +@Suite(.serialized) // 强制串行执行测试,避免并发问题 +final class StoreKitNetworkErrorTests { + private func makeSession() throws -> SKTestSession { + guard let url = Bundle.main.url(forResource: "Configuration", withExtension: "storekit") else { + Issue.record("找不到 Configuration.storekit,请确认 Package.swift resources") + throw NSError(domain: "TestError", code: -1) + } + let session = try SKTestSession(contentsOf: url) + session.disableDialogs = true + session.clearTransactions() + session.resetToDefaultState() + return session + } + + /// 辅助方法:等待并验证购买状态更新 + private func waitAndVerifyPurchaseState(store: StoreContext, expectedHasPurchased: Bool, expectedProductID: String? = nil, timeout: Duration = .milliseconds(1000)) async throws { + let startTime = Date() + let timeoutInterval = TimeInterval(timeout.components.seconds) + TimeInterval(timeout.components.attoseconds) / 1_000_000_000_000_000_000 + + while Date().timeIntervalSince(startTime) < timeoutInterval { + await store.restorePurchases() + try await Task.sleep(for: .milliseconds(100)) + + let currentState = await store.hasPurchased + if currentState == expectedHasPurchased { + if let productID = expectedProductID { + let hasProduct = await store.isPurchased(productID) + if hasProduct == expectedHasPurchased { + return + } + } else { + return + } + } + } + + // 如果超时,记录当前状态用于调试 + let finalState = await store.hasPurchased + let finalProductIDs = await store.purchasedProductIDs + Issue.record("等待购买状态更新超时. 期望 hasPurchased: \(expectedHasPurchased), 实际: \(finalState), 产品IDs: \(finalProductIDs)") + } + + @Test("StoreContext initialization") + func testStoreContextInitialization() async throws { + let session = try makeSession() + let store = await StoreContext(products: AppProduct.allCases) + try await Task.sleep(for: .milliseconds(500)) + #expect(await store.products.count == 2) + #expect(await store.purchasedProductIDs.count == 0) + #expect(await store.hasNotPurchased == true) + #expect(await store.hasPurchased == false) + let product = await store.products.first(where: { $0.id == AppProduct.lifetime.id }) + #expect(product != nil) + #expect(product?.id == AppProduct.lifetime.id) + session.clearTransactions() + session.resetToDefaultState() + try await session.setSimulatedError(nil, forAPI: .loadProducts) + } + + @Test("InAppProduct networkError when loading products") + func testNetworkErrorStrict() async throws { + let session = try makeSession() + let urlError = URLError(.cannotConnectToHost) + try await session.setSimulatedError(.generic(.networkError(urlError)), forAPI: .loadProducts) + let store = await StoreContext(products: AppProduct.allCases) + /// 异步 + try await Task.sleep(for: .milliseconds(500)) + #expect(await store.products.count == 0) + #expect(await store.purchasedProductIDs.count == 0) + #expect(await store.hasNotPurchased == true) + #expect(await store.hasPurchased == false) + session.clearTransactions() + session.resetToDefaultState() + //try await Task.sleep(for: .seconds(5)) + } + + @Test func testPurchaseSuccess() async throws { + let session = try makeSession() + let store = await StoreContext(products: AppProduct.allCases) + /// 异步 + try await Task.sleep(for: .milliseconds(500)) + #expect(await store.products.count == 2) + #expect(await store.purchasedProductIDs.count == 0) + #expect(await store.hasNotPurchased == true) + #expect(await store.hasPurchased == false) + let lifetime = await store.products.first(where: { $0.id == AppProduct.lifetime.id }) + #expect(lifetime != nil) + #expect(lifetime?.id == AppProduct.lifetime.id) + if let lifetime { + session.disableDialogs = true + await store.purchase(lifetime) + } + #expect(await store.hasPurchased == true) + #expect(await store.purchasedProductIDs.contains(AppProduct.lifetime.id) == true) + session.clearTransactions() + session.resetToDefaultState() + #expect(await store.hasPurchased == true) + #expect(await store.purchasedProductIDs.contains(AppProduct.lifetime.id) == true) + session.disableDialogs = true + await store.restorePurchases() + #expect(await store.hasPurchased == false) + session.clearTransactions() + session.resetToDefaultState() + } + + @Test("Purchase and expire monthly subscription") + func testMonthlySubscriptionPurchaseAndExpiry() async throws { + let session = try makeSession() + // 设置快速时间流逝,月度订阅每30秒续订一次 + //session.timeRate = SKTestSession.TimeRate.monthlyRenewalEveryThirtySeconds + let store = await StoreContext(products: AppProduct.allCases) + try await Task.sleep(for: .milliseconds(500)) + + let monthly = await store.products.first(where: { $0.id == AppProduct.monthly.id }) + #expect(monthly != nil) + #expect(monthly?.id == AppProduct.monthly.id) + if let monthly { + session.disableDialogs = true + // 购买订阅 + await store.purchase(monthly) + } + + // 验证购买成功 + #expect(await store.hasPurchased == true) + #expect(await store.purchasedProductIDs.contains(AppProduct.monthly.id) == true) + + // 直接让订阅过期 + try session.expireSubscription(productIdentifier: AppProduct.monthly.id) + + // 更新购买状态 + session.disableDialogs = true + await store.restorePurchases() + + // 验证订阅已过期 + #expect(await store.purchasedProductIDs.contains(AppProduct.monthly.id) == false) + #expect(await store.hasPurchased == false) + + // 清理 + session.clearTransactions() + session.resetToDefaultState() + } +} diff --git a/Example/ExampleUITests/ExampleUITests.swift b/Example/ExampleUITests/ExampleUITests.swift new file mode 100644 index 0000000..85912bd --- /dev/null +++ b/Example/ExampleUITests/ExampleUITests.swift @@ -0,0 +1,41 @@ +// +// ExampleUITests.swift +// ExampleUITests +// +// Created by wong on 12/28/25. +// + +import XCTest + +final class ExampleUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/Example/ExampleUITests/ExampleUITestsLaunchTests.swift b/Example/ExampleUITests/ExampleUITestsLaunchTests.swift new file mode 100644 index 0000000..7bd4244 --- /dev/null +++ b/Example/ExampleUITests/ExampleUITestsLaunchTests.swift @@ -0,0 +1,33 @@ +// +// ExampleUITestsLaunchTests.swift +// ExampleUITests +// +// Created by wong on 12/28/25. +// + +import XCTest + +final class ExampleUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/Package.swift b/Package.swift index 9f24518..29a0c55 100755 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -24,11 +24,14 @@ let package = Package( // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( - name: "StoreKitHelper", + name: "StoreKitHelper" + ), + .testTarget( + name: "StoreKitHelperTests", + dependencies: ["StoreKitHelper"], resources: [ - .process("Resources") + .copy("Configuration.storekit") ] ), - ] ) diff --git a/README.md b/README.md index ab4e95c..3e392d0 100755 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@
- Using my app is also a way to support me: + Using my apps is also a way to support me:
Deskmark Keyzer @@ -39,7 +39,7 @@ StoreKit Helper [中文](./README.zh.md) -A lightweight StoreKit2 wrapper designed specifically for SwiftUI, making it easier to implement in-app purchases. +A lightweight StoreKit2 wrapper designed specifically for SwiftUI, making in-app purchases implementation simpler and more intuitive. ![StoreKit Helper](https://github.com/user-attachments/assets/d0d27552-9d2d-4a09-8d8d-b96b3b3648a9) @@ -47,9 +47,17 @@ A lightweight StoreKit2 wrapper designed specifically for SwiftUI, making it eas Please refer to the detailed `StoreKitHelper` [documentation](https://github.com/jaywcjlove/devtutor) in [DevTutor](https://github.com/jaywcjlove/devtutor), which includes multiple quick start examples, custom payment interface examples, and API references, providing comprehensive examples and guidance. +## Features + +- 🚀 **SwiftUI Native**: Designed specifically for SwiftUI with `@ObservableObject` and `@EnvironmentObject` support +- 💡 **Simple API**: Clean and intuitive interface for managing in-app purchases +- 🔄 **Automatic Updates**: Real-time transaction monitoring and status updates +- ✅ **Type Safe**: Protocol-based product definitions with compile-time safety +- 🧪 **Testable**: Fully testable architecture with comprehensive test case coverage + ## Usage -At the entry point of the SwiftUI application, create and inject a `StoreContext` instance, which is responsible for loading the product list and tracking purchase status. +Create and inject a `StoreContext` instance at your SwiftUI app's entry point, which is responsible for loading the product list and tracking purchase status. ```swift import StoreKitHelper @@ -70,30 +78,49 @@ enum AppProduct: String, InAppProduct { } ``` -Use `StoreKitHelperView` to directly display an in-app purchase popup view and configure various parameters through a chained API. +You can use the `hasNotPurchased` or `hasPurchased` properties in `StoreContext` to check if the user has made a purchase, then dynamically display different interface content. For example: + +```swift +@EnvironmentObject var store: StoreContext + +var body: some View { + if store.hasNotPurchased == true { + // 🧾 User hasn't purchased - show limited content or purchase prompt + } else { + // ✅ User has purchased - show full functionality + } + if store.hasPurchased == true { + // ✅ User has purchased - show full functionality + } else { + // 🧾 User hasn't purchased - show limited content or purchase prompt + } +} +``` + +## StoreKitHelperView + +Use `StoreKitHelperView` to directly display in-app purchase popup views and configure various parameters through a chainable API. ```swift struct PurchaseContent: View { @EnvironmentObject var store: StoreContext var body: some View { + let locale: Locale = Locale(identifier: Locale.preferredLanguages.first ?? "en") StoreKitHelperView() + .environment(\.locale, .init(identifier: locale.identifier)) + .environment(\.pricingContent, { AnyView(PricingContent()) }) + .environment(\.popupDismissHandle, { + // Triggered when the popup is dismissed (e.g., user clicks the close button) + store.isShowingPurchasePopup = false + }) + .environment(\.termsOfServiceHandle, { + // Action triggered when the [Terms of Service] button is clicked + }) + .environment(\.privacyPolicyHandle, { + // Action triggered when the [Privacy Policy] button is clicked + }) .frame(maxWidth: 300) .frame(minWidth: 260) - // Triggered when the popup is dismissed (e.g., user clicks the close button) - .onPopupDismiss { - store.isShowingPurchasePopup = false - } - // Sets the content area displayed in the purchase interface - // (can include feature descriptions, version comparisons, etc.) - .pricingContent { - AnyView(PricingContent()) - } - .termsOfService { - // Action triggered when the [Terms of Service] button is clicked - } - .privacyPolicy { - // Action triggered when the [Privacy Policy] button is clicked - } } } ``` @@ -101,13 +128,12 @@ struct PurchaseContent: View { Click to open the paid product list interface. ```swift -struct PurchaseButton: View { +struct ContentView: View { @EnvironmentObject var store: StoreContext var body: some View { if store.hasNotPurchased == true { PurchasePopupButton() .sheet(isPresented: $store.isShowingPurchasePopup) { - /// Popup with the paid product list PurchaseContent() } } @@ -115,52 +141,62 @@ struct PurchaseButton: View { } ``` -You can use the `hasNotPurchased` property in `StoreContext` to check if the user has made a purchase, and then dynamically display different interface content. For example: +## StoreKitHelperSelectionView + +Similar to `StoreKitHelperView`, but for selecting purchase items to make payments. ```swift -@EnvironmentObject var store: StoreContext - -var body: some View { - if store.hasNotPurchased == true { - // 🧾 User has not purchased - Show restricted content or prompt for purchase - } else { - // ✅ User has purchased - Show full features +struct PurchaseContent: View { + @EnvironmentObject var store: StoreContext + var body: some View { + let locale: Locale = Locale(identifier: Locale.preferredLanguages.first ?? "en") + StoreKitHelperSelectionView() + .environment(\.locale, .init(identifier: locale.identifier)) + .environment(\.pricingContent, { AnyView(PricingContent()) }) + .environment(\.popupDismissHandle, { + // Triggered when the popup is dismissed (e.g., user clicks the close button) + store.isShowingPurchasePopup = false + }) + .environment(\.termsOfServiceHandle, { + // Action triggered when the [Terms of Service] button is clicked + }) + .environment(\.privacyPolicyHandle, { + // Action triggered when the [Privacy Policy] button is clicked + }) + .frame(maxWidth: 300) + .frame(minWidth: 260) } } ``` -### filteredProducts +## API Reference + +### InAppProduct Protocol -This is a simple migration solution: the product list is filtered by product ID, retaining the old product IDs so existing users don’t need to repurchase and can restore their purchases, while new users purchase through the new product IDs, achieving a smooth transition. - ```swift -enum AppProduct: String, InAppProduct { - /// old - case sponsor = "focuscursor.Sponsor" - case generous = "focuscursor.Generous" - /// new - case monthly = "focuscursor.monthly" - case lifetime = "focuscursor.lifetime" - var id: String { rawValue } +protocol InAppProduct: CaseIterable { + var id: String { get } } - -StoreKitHelperView() - .filteredProducts() { productID, product in - if productID == AppProduct.sponsor.rawValue { - return false - } - if productID == AppProduct.generous.rawValue { - return false - } - return true - } - -StoreKitHelperSelectionView() - .filteredProducts() { productID, product in - return true - } ``` +### StoreContext Properties + +- `products: [Product]` - Available products from the App Store +- `purchasedProductIDs: Set` - Set of purchased product identifiers +- `hasNotPurchased: Bool` - Whether the user hasn't purchased any products +- `hasPurchased: Bool` - Whether the user has purchased any products +- `isLoading: Bool` - Whether products are currently loading +- `errorMessage: String?` - Current error message, if any + +### StoreContext Methods + +- `purchase(_ product: Product)` - Purchase a specific product +- `restorePurchases()` - Restore previous purchases +- `isPurchased(_ productID: ProductID) -> Bool` - Check if a product is purchased by ID +- `isPurchased(_ product: InAppProduct) -> Bool` - Check if a product is purchased +- `product(for productID: ProductID) -> Product?` - Get product by ID +- `product(for product: InAppProduct) -> Product?` - Get product by InAppProduct + ## License Licensed under the MIT License. diff --git a/README.zh.md b/README.zh.md index a5de92d..67a01b5 100644 --- a/README.zh.md +++ b/README.zh.md @@ -37,7 +37,7 @@ StoreKit Helper === -[English](./README.zh.md) +[English](./README.md) 专为 SwiftUI 设计的轻量级 StoreKit2 包装器,让应用内购买的实现更加简单。 @@ -47,6 +47,14 @@ StoreKit Helper 请参阅 [DevTutor](https://github.com/jaywcjlove/devtutor) 中详细的 `StoreKitHelper` [文档](https://github.com/jaywcjlove/devtutor),其中包括多个快速入门示例、自定义支付界面示例和 API 参考,提供全面的示例和指导。 +## 功能特性 + +- 🚀 **SwiftUI 原生**: 专为 SwiftUI 设计,支持 `@ObservableObject` 和 `@EnvironmentObject` +- 💡 **简洁 API**: 干净直观的应用内购买管理接口 +- 🔄 **自动更新**: 实时交易监控和状态更新 +- ✅ **类型安全**: 基于协议的产品定义,提供编译时安全性 +- 🧪 **可测试**: 完全可测试的架构,测试用例覆盖 + ## 使用方法 在 SwiftUI 应用程序的入口点创建并注入一个 `StoreContext` 实例,它负责加载产品列表和跟踪购买状态。 @@ -70,52 +78,7 @@ enum AppProduct: String, InAppProduct { } ``` -使用 `StoreKitHelperView` 直接显示应用内购买弹窗视图,并通过链式 API 配置各种参数。 - -```swift -struct PurchaseContent: View { - @EnvironmentObject var store: StoreContext - var body: some View { - StoreKitHelperView() - .frame(maxWidth: 300) - .frame(minWidth: 260) - // 弹窗被关闭时触发(例如用户点击关闭按钮) - .onPopupDismiss { - store.isShowingPurchasePopup = false - } - // 设置在购买界面中显示的内容区域 - // (可包含功能描述、版本对比等) - .pricingContent { - AnyView(PricingContent()) - } - .termsOfService { - // 点击【服务条款】按钮时触发的操作 - } - .privacyPolicy { - // 点击【隐私政策】按钮时触发的操作 - } - } -} -``` - -点击打开付费产品列表界面。 - -```swift -struct PurchaseButton: View { - @EnvironmentObject var store: StoreContext - var body: some View { - if store.hasNotPurchased == true { - PurchasePopupButton() - .sheet(isPresented: $store.isShowingPurchasePopup) { - /// 包含付费产品列表的弹窗 - PurchaseContent() - } - } - } -} -``` - -您可以使用 `StoreContext` 中的 `hasNotPurchased` 属性来检查用户是否已购买,然后动态显示不同的界面内容。例如: +您可以使用 `StoreContext` 中的 `hasNotPurchased` 或 `hasPurchased` 属性来检查用户是否已购买,然后动态显示不同的界面内容。例如: ```swift @EnvironmentObject var store: StoreContext @@ -126,41 +89,113 @@ var body: some View { } else { // ✅ 用户已购买 - 显示完整功能 } + if store.hasPurchased == true { + // ✅ 用户已购买 - 显示完整功能 + } else { + // 🧾 用户未购买 - 显示受限内容或提示购买 + } } ``` -### filteredProducts +## StoreKitHelperView + +使用 `StoreKitHelperView` 直接显示应用内购买弹窗视图,并通过链式 API 配置各种参数。 -这是一个简单的迁移解决方案:产品列表通过产品 ID 进行过滤,保留旧的产品 ID,这样现有用户不需要重新购买并可以恢复他们的购买,而新用户通过新的产品 ID 购买,实现平滑过渡。 - ```swift -enum AppProduct: String, InAppProduct { - /// 旧版本 - case sponsor = "focuscursor.Sponsor" - case generous = "focuscursor.Generous" - /// 新版本 - case monthly = "focuscursor.monthly" - case lifetime = "focuscursor.lifetime" - var id: String { rawValue } +struct PurchaseContent: View { + @EnvironmentObject var store: StoreContext + var body: some View { + let locale: Locale = Locale(identifier: Locale.preferredLanguages.first ?? "en") + StoreKitHelperView() + .environment(\.locale, .init(identifier: locale.identifier)) + .environment(\.pricingContent, { AnyView(PricingContent()) }) + .environment(\.popupDismissHandle, { + // 弹窗被关闭时触发(例如用户点击关闭按钮) + store.isShowingPurchasePopup = false + }) + .environment(\.termsOfServiceHandle, { + // 点击【服务条款】按钮时触发的操作 + }) + .environment(\.privacyPolicyHandle, { + // 点击【隐私政策】按钮时触发的操作 + }) + .frame(maxWidth: 300) + .frame(minWidth: 260) + } } - -StoreKitHelperView() - .filteredProducts() { productID, product in - if productID == AppProduct.sponsor.rawValue { - return false - } - if productID == AppProduct.generous.rawValue { - return false - } - return true - } - -StoreKitHelperSelectionView() - .filteredProducts() { productID, product in - return true - } ``` +点击打开付费产品列表界面。 + +```swift +struct ContentView: View { + @EnvironmentObject var store: StoreContext + var body: some View { + if store.hasNotPurchased == true { + PurchasePopupButton() + .sheet(isPresented: $store.isShowingPurchasePopup) { + PurchaseContent() + } + } + } +} +``` + +## StoreKitHelperSelectionView + +跟 `StoreKitHelperView` 差不多,选择购买项进行支付。 + +```swift +struct PurchaseContent: View { + @EnvironmentObject var store: StoreContext + var body: some View { + let locale: Locale = Locale(identifier: Locale.preferredLanguages.first ?? "en") + StoreKitHelperSelectionView() + .environment(\.locale, .init(identifier: locale.identifier)) + .environment(\.pricingContent, { AnyView(PricingContent()) }) + .environment(\.popupDismissHandle, { + // 弹窗被关闭时触发(例如用户点击关闭按钮) + store.isShowingPurchasePopup = false + }) + .environment(\.termsOfServiceHandle, { + // 点击【服务条款】按钮时触发的操作 + }) + .environment(\.privacyPolicyHandle, { + // 点击【隐私政策】按钮时触发的操作 + }) + .frame(maxWidth: 300) + .frame(minWidth: 260) + } +} +``` +## API 参考 + +### InAppProduct 协议 + +```swift +protocol InAppProduct: CaseIterable { + var id: String { get } +} +``` + +### StoreContext 属性 + +- `products: [Product]` - 从 App Store 获取的可用产品列表 +- `purchasedProductIDs: Set` - 已购买产品标识符的集合 +- `hasNotPurchased: Bool` - 用户是否未购买任何产品 +- `hasPurchased: Bool` - 用户是否已购买任何产品 +- `isLoading: Bool` - 产品是否正在加载中 +- `errorMessage: String?` - 当前错误信息(如有) + +### StoreContext 方法 + +- `purchase(_ product: Product)` - 购买指定产品 +- `restorePurchases()` - 恢复之前的购买 +- `isPurchased(_ productID: ProductID) -> Bool` - 根据 ID 检查产品是否已购买 +- `isPurchased(_ product: InAppProduct) -> Bool` - 检查产品是否已购买 +- `product(for productID: ProductID) -> Product?` - 根据 ID 获取产品 +- `product(for product: InAppProduct) -> Product?` - 根据 InAppProduct 获取产品 + ## 许可证 基于 MIT 许可证授权。 diff --git a/Sources/StoreKitHelper/Utils.swift b/Sources/StoreKitHelper/Comps/NotifyAlert.swift old mode 100755 new mode 100644 similarity index 90% rename from Sources/StoreKitHelper/Utils.swift rename to Sources/StoreKitHelper/Comps/NotifyAlert.swift index 8a5c0c1..80514dd --- a/Sources/StoreKitHelper/Utils.swift +++ b/Sources/StoreKitHelper/Comps/NotifyAlert.swift @@ -1,18 +1,19 @@ // -// Untitled.swift +// Notify.swift // StoreKitHelper // -// Created by 王楚江 on 2025/3/5. +// Created by wong on 12/29/25. // + #if os(macOS) import AppKit #else import UIKit #endif -class Utils { - nonisolated(unsafe) static let shared = Utils() +class NotifyAlert { + nonisolated(unsafe) static let shared = NotifyAlert() @MainActor static func alert(title: String, message: String) { #if os(macOS) let alert = NSAlert() diff --git a/Sources/StoreKitHelper/Comps/ProductsLoad.swift b/Sources/StoreKitHelper/Comps/ProductsLoad.swift new file mode 100644 index 0000000..e97c58e --- /dev/null +++ b/Sources/StoreKitHelper/Comps/ProductsLoad.swift @@ -0,0 +1,71 @@ +// +// ProductsLoad.swift +// StoreKitHelper +// +// Created by wong on 12/29/25. +// + +import SwiftUI + + +struct ViewHeightKey: PreferenceKey { + typealias Value = CGFloat + nonisolated(unsafe) static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +struct ProductsLoad: View { + @Environment(\.locale) var locale + @EnvironmentObject var store: StoreContext + @State private var viewHeight: CGFloat? = nil + @ViewBuilder var content: () -> Content + func showError(error: StoreKitError?) -> Bool { + guard let error else { return false } + guard error != .userCancelled else { return false } + guard case .restoreFailed = error else { return true } + return false + } + var body: some View { + ZStack { + let info = showError(error: store.storeError) + if showError(error: store.storeError) == true { + VStack(alignment: .leading, spacing: 6) { + if let error = store.storeError { + Text(error.description(locale: locale)) + .fontWeight(.thin) + .foregroundStyle(Color.red) + } + } + .lineLimit(nil) // 允许多行显示 + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } else { + VStack(spacing: 0) { + content() + } + .overlay { + Group { + if store.isLoading == true { + VStack { + ProgressView().controlSize(.small) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.background.opacity(0.73)) + } + } + } + } + } + .background(GeometryReader { geometry in + Color.clear.preference(key: ViewHeightKey.self, value: geometry.size.height) + }) + .onPreferenceChange(ViewHeightKey.self) { newHeight in + DispatchQueue.main.async { + self.viewHeight = newHeight + } + } + .frame(minHeight: viewHeight) + } +} diff --git a/Sources/StoreKitHelper/Views/PurchasePopupButton.swift b/Sources/StoreKitHelper/Comps/PurchasePopupButton.swift old mode 100755 new mode 100644 similarity index 91% rename from Sources/StoreKitHelper/Views/PurchasePopupButton.swift rename to Sources/StoreKitHelper/Comps/PurchasePopupButton.swift index acdf942..6b6ef66 --- a/Sources/StoreKitHelper/Views/PurchasePopupButton.swift +++ b/Sources/StoreKitHelper/Comps/PurchasePopupButton.swift @@ -1,8 +1,8 @@ // -// BuyButton.swift +// PurchasePopupButton.swift // StoreKitHelper // -// Created by 王楚江 on 2025/3/4. +// Created by wong on 12/29/25. // import SwiftUI diff --git a/Sources/StoreKitHelper/Comps/RestorePurchasesButton.swift b/Sources/StoreKitHelper/Comps/RestorePurchasesButton.swift new file mode 100644 index 0000000..ba4d5f5 --- /dev/null +++ b/Sources/StoreKitHelper/Comps/RestorePurchasesButton.swift @@ -0,0 +1,57 @@ +// +// RestorePurchasesButton.swift +// StoreKitHelper +// +// Created by wong on 12/29/25. +// + + +import SwiftUI + +// MARK: 恢复购买 +/// 恢复购买 +struct RestorePurchasesButton: View { + @Environment(\.locale) var locale + @Environment(\.popupDismissHandle) private var popupDismissHandle + @EnvironmentObject var store: StoreContext + @Binding var restoringPurchase: Bool + func showError(error: StoreKitError?) -> Bool { + guard let error else { return false } + guard error != .userCancelled else { return false } + guard case .restoreFailed = error else { return true } + return false + } + var body: some View { + let noPurchaseTitle = String.localizedString(key: "no_purchase_available", locale: locale) + let restoreFailedTitle = String.localizedString(key: "restore_purchases_failed", locale: locale) + Button(action: { + Task { + restoringPurchase = true + do { + try await store.restorePurchases() + restoringPurchase = false + if store.purchasedProductIDs.count > 0 { + popupDismissHandle?() + } else if showError(error: store.storeError) == true { + NotifyAlert.alert(title: store.storeError?.description(locale: locale) ?? noPurchaseTitle, message: "") + } + } catch { + restoringPurchase = false + NotifyAlert.alert(title: restoreFailedTitle, message: error.localizedDescription) + } + } + }, label: { + HStack { + if restoringPurchase { + ProgressView().controlSize(.mini) + } + Text("restore_purchases", bundle: .module) + } + }) + #if os(macOS) + .buttonStyle(.link) + #endif + .disabled(restoringPurchase) + .environment(\.locale, locale) + } +} diff --git a/Sources/StoreKitHelper/Comps/TermsOfServiceView.swift b/Sources/StoreKitHelper/Comps/TermsOfServiceView.swift new file mode 100644 index 0000000..b4e4319 --- /dev/null +++ b/Sources/StoreKitHelper/Comps/TermsOfServiceView.swift @@ -0,0 +1,90 @@ +// +// TermsOfServiceView.swift +// StoreKitHelper +// +// Created by wong on 12/29/25. +// + + +import SwiftUI + +// MARK: 服务条款 & 隐私政策 +struct TermsOfServiceView: View { + @Environment(\.termsOfServiceHandle) private var termsOfServiceHandle + @Environment(\.privacyPolicyHandle) private var privacyPolicyHandle + + @Environment(\.termsOfServiceLabel) private var termsOfServiceLabel + @Environment(\.privacyPolicyLabel) private var privacyPolicyLabel + @Environment(\.locale) var locale + var body: some View { + if termsOfServiceHandle != nil || privacyPolicyHandle != nil { + Divider() + HStack { + if let action = termsOfServiceHandle { + Button(action: action, label: { + Text(termsOfServiceLabel.isEmpty ? "terms_of_service" : LocalizedStringKey(termsOfServiceLabel), bundle: .module) + .frame(maxWidth: .infinity) + + }) +#if os(macOS) + .buttonStyle(.link) +#elseif os(iOS) + .glassEffectButton() +#endif + } + if let action = privacyPolicyHandle { + Button(action: action, label: { + Text(privacyPolicyLabel.isEmpty ? "privacy_policy" : LocalizedStringKey(privacyPolicyLabel), bundle: .module) + .frame(maxWidth: .infinity) + }) +#if os(macOS) + .buttonStyle(.link) +#elseif os(iOS) + .glassEffectButton() +#endif + } + } + .padding(.horizontal, 8) + .environment(\.locale, locale) + } + } +} + + +func localeBundle(locale: Locale) -> Bundle { + return LocalizedStringKey.getBundle(locale: locale) +} + +extension LocalizedStringKey { + func localizedString(locale: Locale) -> String { + let mirror = Mirror(reflecting: self) + let key = mirror.children.first { $0.label == "key" }?.value as? String ?? "" + let languageCode = locale.identifier + let path = Bundle.main.path(forResource: languageCode, ofType: "lproj") ?? "" + let bundle = Bundle(path: path) ?? .main + return NSLocalizedString(key, bundle: bundle, comment: "") + } + static func getBundle(locale: Locale) -> Bundle { + let languageCode = locale.identifier + let path = Bundle.main.path(forResource: languageCode, ofType: "lproj") ?? "" + return path.isEmpty ? .main : Bundle(path: path)! + } +} + + +#Preview { + VStack(spacing: 0) { + TermsOfServiceView() + .environment(\.termsOfServiceHandle, { + // Action triggered when the [Terms of Service] button is clicked + print("Action triggered when the [Terms of Service] button is clicked") + }) + .environment(\.privacyPolicyHandle, { + // Action triggered when the [Privacy Policy] button is clicked + print("Action triggered when the [Privacy Policy] button is clicked") + }) + .padding(.top, 0) + .padding(.bottom, 8) + } + .frame(width: 560) +} diff --git a/Sources/StoreKitHelper/Views/Environment.swift b/Sources/StoreKitHelper/Extensions/Environment.swift old mode 100755 new mode 100644 similarity index 55% rename from Sources/StoreKitHelper/Views/Environment.swift rename to Sources/StoreKitHelper/Extensions/Environment.swift index 92618e7..184de87 --- a/Sources/StoreKitHelper/Views/Environment.swift +++ b/Sources/StoreKitHelper/Extensions/Environment.swift @@ -1,36 +1,12 @@ // -// EnvironmentEvents.swift +// Environment.swift // StoreKitHelper // -// Created by 王楚江 on 2025/3/4. +// Created by wong on 12/29/25. // - import SwiftUI -struct PopupDismissHandle: @preconcurrency EnvironmentKey { - @MainActor static let defaultValue: (() -> Void)? = nil -} -struct TermsOfServiceHandle: @preconcurrency EnvironmentKey { - @MainActor static let defaultValue: (() -> Void)? = nil -} -struct TermsOfServiceLabel: @preconcurrency EnvironmentKey { - @MainActor static let defaultValue: String = "" -} -struct PrivacyPolicyHandle: @preconcurrency EnvironmentKey { - @MainActor static let defaultValue: (() -> Void)? = nil -} -struct PrivacyPolicyLabel: @preconcurrency EnvironmentKey { - @MainActor static let defaultValue: String = "" -} -// 定义一个环境键,泛型视图类型 -struct PricingContent: EnvironmentKey { - // 使用计算属性来提供默认值 - static var defaultValue: (() -> T)? { - return nil // 可以返回一个默认的视图构造方法 - } -} - public extension EnvironmentValues { var termsOfServiceLabel: String { get { self[TermsOfServiceLabel.self] } @@ -48,11 +24,13 @@ public extension EnvironmentValues { get { self[PrivacyPolicyHandle.self] } set { self[PrivacyPolicyHandle.self] = newValue } } + /// 弹出框,关闭函数 var popupDismissHandle: (() -> Void)? { get { self[PopupDismissHandle.self] } set { self[PopupDismissHandle.self] = newValue } } + /// 定价说明内容 /// 付费说明内容 - 定价说明 var pricingContent: (() -> AnyView)? { @@ -61,28 +39,27 @@ public extension EnvironmentValues { } } -// MARK: - View Extensions -public extension View { - func termsOfService(action: @escaping () -> Void) -> some View { - return self.environment(\.termsOfServiceHandle, action) - } - func termsOfService(label: String, action: @escaping () -> Void) -> some View { - return self.environment(\.termsOfServiceLabel, label) - .environment(\.termsOfServiceHandle, action) - } - func privacyPolicy(action: @escaping () -> Void) -> some View { - return self.environment(\.privacyPolicyHandle, action) - } - func privacyPolicy(label: String, action: @escaping () -> Void) -> some View { - return self.environment(\.privacyPolicyLabel, label) - .environment(\.privacyPolicyHandle, action) - } - /// 弹出框,关闭函数 - func onPopupDismiss(action: @escaping () -> Void) -> some View { - return self.environment(\.popupDismissHandle, action) - } - /// 定价说明 - func pricingContent(@ViewBuilder content: @escaping () -> T) -> some View { - return self.environment(\.pricingContent, { AnyView(content()) }) +struct PopupDismissHandle: @preconcurrency EnvironmentKey { + @MainActor static let defaultValue: (() -> Void)? = nil +} + +// 定义一个环境键,泛型视图类型 +struct PricingContent: EnvironmentKey { + // 使用计算属性来提供默认值 + static var defaultValue: (() -> T)? { + return nil // 可以返回一个默认的视图构造方法 } } + +struct TermsOfServiceHandle: @preconcurrency EnvironmentKey { + @MainActor static let defaultValue: (() -> Void)? = nil +} +struct TermsOfServiceLabel: EnvironmentKey { + static let defaultValue: String = "" +} +struct PrivacyPolicyHandle: @preconcurrency EnvironmentKey { + @MainActor static let defaultValue: (() -> Void)? = nil +} +struct PrivacyPolicyLabel: EnvironmentKey { + static let defaultValue: String = "" +} diff --git a/Sources/StoreKitHelper/Extensions/Notification.swift b/Sources/StoreKitHelper/Extensions/Notification.swift new file mode 100644 index 0000000..18b936e --- /dev/null +++ b/Sources/StoreKitHelper/Extensions/Notification.swift @@ -0,0 +1,11 @@ +// +// Notification.swift +// StoreKitHelper +// +// Created by wong on 12/29/25. +// + +import SwiftUI + +extension Notification.Name { +} diff --git a/Sources/StoreKitHelper/Extensions/String.swift b/Sources/StoreKitHelper/Extensions/String.swift index 2e249c1..c8e66ad 100755 --- a/Sources/StoreKitHelper/Extensions/String.swift +++ b/Sources/StoreKitHelper/Extensions/String.swift @@ -2,84 +2,19 @@ // String.swift // StoreKitHelper // -// Created by Wang Chujiang on 2025/3/5. +// Created by wong on 12/29/25. // -import Foundation - -public extension String { - func localized() -> String { - return NSLocalizedString(self, bundle: .module, comment: "") - } - func localized(locale: Locale = Locale.current) -> String { - localized(locale: locale, arguments: []) - } - func localized(arguments: any CVarArg...) -> String { - return String(format: NSLocalizedString(self, bundle: .module, comment: ""), arguments) - } -} +import SwiftUI internal extension String { - func localized(locale: Locale = Locale.current, arguments: any CVarArg...) -> String { - // Get language and region codes - let languageCode = locale.language.languageCode?.identifier ?? "" - let regionCode = locale.region?.identifier ?? "" - - // Map region code to corresponding language - var targetLanguage = languageCode - - // Region code to language mapping - let regionToLanguageMap: [String: String] = [ - // Chinese regions - "CN": "zh-Hans", // Mainland China -> Simplified Chinese - "SG": "zh-Hans", // Singapore -> Simplified Chinese - "TW": "zh-Hant", // Taiwan -> Traditional Chinese - "HK": "zh-Hant", // Hong Kong -> Traditional Chinese - "MO": "zh-Hant", // Macau -> Traditional Chinese - - // Other language regions - "JP": "ja", // Japan -> Japanese - "KR": "ko", // South Korea -> Korean - "DE": "de", // Germany -> German - "AT": "de", // Austria -> German - "CH": "de", // Switzerland -> German (partial regions) - "FR": "fr", // France -> French - "BE": "fr", // Belgium -> French (partial regions) - "CA": "fr", // Canada -> French (partial regions) - ] - - // First check region mapping - if let mappedLanguage = regionToLanguageMap[regionCode] { - targetLanguage = mappedLanguage - } else if languageCode == "zh" { - // If language is Chinese but region has no mapping, default to Simplified Chinese - targetLanguage = "zh-Hans" + static func localizedString(key: String, locale: Locale, _ arguments: any CVarArg...) -> String { + guard let path = Bundle.module.path(forResource: locale.identifier, ofType: "lproj"), + let bundle = Bundle(path: path) else { + let format = NSLocalizedString(key, bundle: .module, comment: "") + return String.localizedStringWithFormat(format, arguments) } - - // Try to find localization files, search by priority: - // 1. Region-mapped language (e.g., zh-Hans, zh-Hant) - // 2. Original language code (e.g., en, fr, de, etc.) - // 3. English as fallback - var path = Bundle.module.path(forResource: targetLanguage, ofType: "lproj") - - if path == nil && targetLanguage != languageCode { - path = Bundle.module.path(forResource: languageCode, ofType: "lproj") - } - - if path == nil && targetLanguage != "en" && languageCode != "en" { - path = Bundle.module.path(forResource: "en", ofType: "lproj") - } - - guard let validPath = path else { - return NSLocalizedString(self, tableName: nil, bundle: Bundle.module, comment: "") - } - - let languageBundle = Bundle(path: validPath) - let localizedString = NSLocalizedString(self, tableName: nil, bundle: languageBundle ?? Bundle.module, comment: "") - - if arguments.count > 0 { - return String(format: localizedString, arguments) - } - return localizedString + let format = NSLocalizedString(key, bundle: bundle, comment: "") + return String.localizedStringWithFormat(format, arguments) } } diff --git a/Sources/StoreKitHelper/Extensions/View.swift b/Sources/StoreKitHelper/Extensions/View.swift index 009c5df..cd47363 100644 --- a/Sources/StoreKitHelper/Extensions/View.swift +++ b/Sources/StoreKitHelper/Extensions/View.swift @@ -2,19 +2,37 @@ // View.swift // StoreKitHelper // -// Created by wong on 10/1/25. +// Created by wong on 12/29/25. // + import SwiftUI internal extension View { - @ViewBuilder func glassEffectButton() -> some View { - if #available(macOS 26.0, iOS 26, *) { - self.buttonStyle(.plain) - .padding(.vertical, 5) - .glassEffect(.regular.interactive(), in: .capsule) +// @ViewBuilder func glassEffectButton() -> some View { +// if #available(macOS 26.0, iOS 26, *) { +// self.buttonStyle(.plain) +// .padding(.vertical, 5) +// .glassEffect(.regular.interactive(), in: .capsule) +// } else { +// self +// } +// } + @ViewBuilder func glassEffectButton(in shape: some Shape = .capsule, color: Color? = nil) -> some View { + if #available(macOS 26.0, iOS 26.0, *) { + self.padding(.horizontal, 10) + .padding(.vertical, 4) + .contentShape(Rectangle()) + .glassEffect(color != nil ? .regular.tint(color): .regular, in: shape) } else { - self + self.tint(color) + } + } + @ViewBuilder func glassButtonStyle() -> some View { + if #available(macOS 26.0, iOS 26.0, *) { + self.buttonStyle(.plain) + } else { + self.buttonStyle(.borderedProminent) } } } diff --git a/Sources/StoreKitHelper/Products/InAppProduct.swift b/Sources/StoreKitHelper/Products/InAppProduct.swift deleted file mode 100755 index 5c7b801..0000000 --- a/Sources/StoreKitHelper/Products/InAppProduct.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// InAppProduct.swift -// StoreKitHelper -// -// Created by 王楚江 on 2025/3/8. -// - -public protocol InAppProduct: CaseIterable, Identifiable where ID == ProductID { - var id: ProductID { get } -} - -/** - ```swift - enum AppProduct: String, InAppProduct { - case lifetime = "xxx.lifetime" - case annually = "xxx.annually" - case monthly = "xxx.monthly" - var id: String { rawValue } - } - let products = AppProduct.allCases - var store = StoreContext(products: AppProduct.allCases) - let available = products.available(in: store) - let purchased = products.purchased(in: store) - ``` - */ -public extension Collection where Element: InAppProduct { - /// Get all products available in a ``StoreContext``. - func available(in context: StoreContext) -> [Self.Element] { - let ids = context.productIds - return self.filter { ids.contains($0.id) } - } - /// Get all products purchased in a ``StoreContext``. - func purchased(in context: StoreContext) -> [Self.Element] { - let ids = context.purchasedProductIds - return self.filter { ids.contains($0.id) } - } -} diff --git a/Sources/StoreKitHelper/Products/Product.swift b/Sources/StoreKitHelper/Products/Product.swift deleted file mode 100755 index 9f14618..0000000 --- a/Sources/StoreKitHelper/Products/Product.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Product.swift -// StoreKitHelper -// -// Created by 王楚江 on 2025/3/8. -// - -import StoreKit - -public extension Product { - /// 通过本地 `产品ID` 获取 AppStore 产品。 - static func products(for representations: [T]) async throws -> [Product] { - let ids = representations.map { $0.id } - return try await products(for: ids) - } -} diff --git a/Sources/StoreKitHelper/Products/ProductID.swift b/Sources/StoreKitHelper/Products/ProductID.swift deleted file mode 100755 index 8a7b64d..0000000 --- a/Sources/StoreKitHelper/Products/ProductID.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// ProductID.swift -// StoreKitHelper -// -// Created by 王楚江 on 2025/3/8. -// - - -/// 定义的产品 ID 这是`固定`的 -public typealias ProductID = String - -/// 请求的产品 ID -public typealias ProductFetchID = String diff --git a/Sources/StoreKitHelper/Resources/de.lproj/Localizable.strings b/Sources/StoreKitHelper/Resources/de.lproj/Localizable.strings index 11906be..df2177a 100644 --- a/Sources/StoreKitHelper/Resources/de.lproj/Localizable.strings +++ b/Sources/StoreKitHelper/Resources/de.lproj/Localizable.strings @@ -11,12 +11,22 @@ "restore_purchases" = "Käufe wiederherstellen"; "restore_purchases_failed" = "Wiederherstellung der Käufe fehlgeschlagen:"; "no_purchase_available" = "Keine wiederherstellbaren Käufe verfügbar"; -"network_connection_check" = "Überprüfen Sie die Netzwerkverbindung. Wenn diese normal ist, aber der Store immer noch nicht verfügbar ist, versuchen Sie, die App [neu zu starten](restartapp)."; "store_unavailable" = "Store nicht verfügbar"; "no_in_app_purchases" = "Aktuell sind keine In-App-Käufe im Store verfügbar."; +"network_connection_check" = "Überprüfen Sie die Netzwerkverbindung. Wenn diese normal ist, aber der Store immer noch nicht verfügbar ist, versuchen Sie, die App [neu zu starten](restartapp)."; "purchase" = "Kaufen"; "purchase_failed" = "Kauf fehlgeschlagen"; "unlock_premium" = "Premium freischalten"; + +/* StoreKit Error Messages */ +"product_load_failed" = "Produkte konnten nicht geladen werden: %@"; +"purchase_failed_with_error" = "Kauf fehlgeschlagen: %@"; +"restore_failed_with_error" = "Wiederherstellung der Käufe fehlgeschlagen: %@"; +"verification_failed" = "Transaktionsverifizierung fehlgeschlagen"; +"network_error" = "Netzwerkfehler: %@"; +"user_cancelled" = "Benutzer hat den Kauf abgebrochen"; +"purchase_pending" = "Kauf wartet auf Genehmigung"; +"unknown_error" = "Unbekannter Fehler: %@"; diff --git a/Sources/StoreKitHelper/Resources/en.lproj/Localizable.strings b/Sources/StoreKitHelper/Resources/en.lproj/Localizable.strings index fe40a32..b1a49de 100755 --- a/Sources/StoreKitHelper/Resources/en.lproj/Localizable.strings +++ b/Sources/StoreKitHelper/Resources/en.lproj/Localizable.strings @@ -20,3 +20,13 @@ "purchase_failed" = "Purchase Failed"; "unlock_premium" = "Unlock Premium"; + +/* StoreKit Error Messages */ +"product_load_failed" = "Failed to load products: %@"; +"purchase_failed_with_error" = "Purchase failed: %@"; +"restore_failed_with_error" = "Restore purchases failed: %@"; +"verification_failed" = "Transaction verification failed"; +"network_error" = "Network error: %@"; +"user_cancelled" = "User cancelled purchase"; +"purchase_pending" = "Purchase is pending approval"; +"unknown_error" = "Unknown error: %@"; diff --git a/Sources/StoreKitHelper/Resources/fr.lproj/Localizable.strings b/Sources/StoreKitHelper/Resources/fr.lproj/Localizable.strings index e6dd3c5..d3d5b99 100644 --- a/Sources/StoreKitHelper/Resources/fr.lproj/Localizable.strings +++ b/Sources/StoreKitHelper/Resources/fr.lproj/Localizable.strings @@ -9,7 +9,7 @@ "privacy_policy" = "Politique de confidentialité"; "restore_purchases" = "Restaurer les achats"; -"restore_purchases_failed" = "Échec de la restauration des achats :"; +"restore_purchases_failed" = "Échec de la restauration des achats:"; "no_purchase_available" = "Aucun achat à restaurer"; "store_unavailable" = "Magasin indisponible"; @@ -20,3 +20,13 @@ "purchase_failed" = "Échec de l'achat"; "unlock_premium" = "Déverrouiller Premium"; + +/* Messages d'erreur StoreKit */ +"product_load_failed" = "Échec du chargement des produits : %@"; +"purchase_failed_with_error" = "Échec de l'achat : %@"; +"restore_failed_with_error" = "Échec de la restauration des achats : %@"; +"verification_failed" = "Échec de la vérification de la transaction"; +"network_error" = "Erreur réseau : %@"; +"user_cancelled" = "L'utilisateur a annulé l'achat"; +"purchase_pending" = "L'achat est en attente d'approbation"; +"unknown_error" = "Erreur inconnue : %@"; diff --git a/Sources/StoreKitHelper/Resources/ja.lproj/Localizable.strings b/Sources/StoreKitHelper/Resources/ja.lproj/Localizable.strings index 7c178a2..f126d14 100644 --- a/Sources/StoreKitHelper/Resources/ja.lproj/Localizable.strings +++ b/Sources/StoreKitHelper/Resources/ja.lproj/Localizable.strings @@ -20,3 +20,13 @@ "purchase_failed" = "購入に失敗しました"; "unlock_premium" = "プレミアムを解除"; + +/* StoreKitエラーメッセージ */ +"product_load_failed" = "製品の読み込みに失敗しました: %@"; +"purchase_failed_with_error" = "購入に失敗しました: %@"; +"restore_failed_with_error" = "購入の復元に失敗しました: %@"; +"verification_failed" = "取引の検証に失敗しました"; +"network_error" = "ネットワークエラー: %@"; +"user_cancelled" = "ユーザーが購入をキャンセルしました"; +"purchase_pending" = "購入は承認待ちです"; +"unknown_error" = "不明なエラー: %@"; diff --git a/Sources/StoreKitHelper/Resources/ko.lproj/Localizable.strings b/Sources/StoreKitHelper/Resources/ko.lproj/Localizable.strings index 0f5920e..5875d19 100644 --- a/Sources/StoreKitHelper/Resources/ko.lproj/Localizable.strings +++ b/Sources/StoreKitHelper/Resources/ko.lproj/Localizable.strings @@ -20,3 +20,13 @@ "purchase_failed" = "구매 실패"; "unlock_premium" = "프리미엄 잠금 해제"; + +/* StoreKit 오류 메시지 */ +"product_load_failed" = "제품 로드 실패: %@"; +"purchase_failed_with_error" = "구매 실패: %@"; +"restore_failed_with_error" = "구매 복원 실패: %@"; +"verification_failed" = "거래 검증 실패"; +"network_error" = "네트워크 오류: %@"; +"user_cancelled" = "사용자가 구매를 취소했습니다"; +"purchase_pending" = "구매가 승인 대기 중입니다"; +"unknown_error" = "알 수 없는 오류: %@"; diff --git a/Sources/StoreKitHelper/Resources/zh-Hans.lproj/Localizable.strings b/Sources/StoreKitHelper/Resources/zh-Hans.lproj/Localizable.strings index a12eaf2..45f4c35 100755 --- a/Sources/StoreKitHelper/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/StoreKitHelper/Resources/zh-Hans.lproj/Localizable.strings @@ -20,3 +20,13 @@ "purchase_failed" = "购买失败"; "unlock_premium" = "付费解锁"; + +/* StoreKit 错误消息 */ +"product_load_failed" = "加载产品失败: %@"; +"purchase_failed_with_error" = "购买失败: %@"; +"restore_failed_with_error" = "恢复购买失败: %@"; +"verification_failed" = "交易验证失败"; +"network_error" = "网络错误: %@"; +"user_cancelled" = "用户取消购买"; +"purchase_pending" = "购买等待审批中"; +"unknown_error" = "未知错误: %@"; diff --git a/Sources/StoreKitHelper/Resources/zh-Hant.lproj/Localizable.strings b/Sources/StoreKitHelper/Resources/zh-Hant.lproj/Localizable.strings index 825ebbf..f483b47 100644 --- a/Sources/StoreKitHelper/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/StoreKitHelper/Resources/zh-Hant.lproj/Localizable.strings @@ -20,3 +20,14 @@ "purchase_failed" = "購買失敗"; "unlock_premium" = "解鎖高級功能"; + +/* StoreKit 錯誤訊息 */ +"product_load_failed" = "載入產品失敗: %@"; +"purchase_failed_with_error" = "購買失敗: %@"; +"restore_failed_with_error" = "恢復購買失敗: %@"; +"verification_failed" = "交易驗證失敗"; +"network_error" = "網路錯誤: %@"; +"user_cancelled" = "使用者取消購買"; +"purchase_pending" = "購買等待審核中"; +"unknown_error" = "未知錯誤: %@"; + diff --git a/Sources/StoreKitHelper/StoreContext+CheckReceipt.swift b/Sources/StoreKitHelper/StoreContext+CheckReceipt.swift deleted file mode 100644 index a219313..0000000 --- a/Sources/StoreKitHelper/StoreContext+CheckReceipt.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// StoreContext+CheckReceipt.swift -// StoreKitHelper -// -// Created by wong on 3/14/25. -// - -import Foundation -import StoreKit - -extension StoreContext { - /// 检查收据的存在 - /// 返回当前有效的(未过期的)购买或订阅记录。适用于验证当前活跃的订阅或购买权限。 - func checkReceipt() async -> Bool { - guard Bundle.main.appStoreReceiptURL != nil else { - exitWithStatus173() - return false - } - var hasValidTransaction = false - /// 返回一个包含当前有效的 已购买商品 的交易列表 - let entitlements = Transaction.currentEntitlements - for await result in entitlements { - switch result { - case let .verified(transaction): - if let transaction = try? await getValidTransaction(for: transaction.productID) { - await self.updatePurchaseTransactions(with: transaction) - hasValidTransaction = true - } - case let .unverified(_, error): - print("Unverified transaction: \(error.localizedDescription)") - } - } - return hasValidTransaction - } - - // 退出应用并返回状态码 173 - private func exitWithStatus173() { - exit(173) - } -} diff --git a/Sources/StoreKitHelper/StoreContext+Products.swift b/Sources/StoreKitHelper/StoreContext+Products.swift deleted file mode 100755 index eeef568..0000000 --- a/Sources/StoreKitHelper/StoreContext+Products.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// StoreContext+Products.swift -// StoreKitHelper -// -// Created by 王楚江 on 2025/3/4. -// - -import StoreKit - -public extension StoreContext { - /// 是否有购买 - /// 返回 `true` 需要购买 - var hasNotPurchased: Bool { - purchasedProductIds.count == 0 - } - /// 产品购买了 - func isProductPurchased(id: ProductID) -> Bool { - purchasedProductIds.contains(id) - } - func isProductPurchased(_ product: Product) -> Bool { - isProductPurchased(id: product.id) - } - /// - Parameters: - /// - id: The ID of the product to fetch. - func product(withId id: ProductFetchID) -> Product? { - products.first { $0.id == id } - } - func getProducts() async throws -> [Product] { - /// ⚠️ 苹果缓`存机制`导致,列表获取为`空` - /// `重现问题:` 如果网络先断掉,启动应用,无法获取应用可购买的产品列表,再打开应用,获取仍然无法获取产品列表,需要重启应用,才能重新获取 - /// 暂时没有找到解决方案,在支付界面提示用户`重启应用` -// return try await Product.products(for: ["focuscursor.lifetime", "focuscursor.monthly.unlock"]) - return try await Product.products(for: self.productIds) - } - // MARK: - 购买 - /// 购买某个产品 - @discardableResult - func purchase(_ product: Product) async throws -> (Product.PurchaseResult, Transaction?) { - let result = try await purchaseResult(product) - if let transaction = result.1 { - await updatePurchaseTransactions(with: transaction) - } - return result - } - @discardableResult - func purchaseResult(_ product: Product) async throws -> (Product.PurchaseResult, Transaction?) { - let result = try await product.purchase() - var transaction: Transaction? = nil - switch result { - case .success(let result): - switch result { - case .verified(let verifiedTransaction): - transaction = verifiedTransaction // 提取已验证的 Transaction - try await finalizePurchaseResult(result) // 处理已验证的交易 - case .unverified: break - } - case .pending: break - case .userCancelled: break - @unknown default: break - } - - return (result, transaction) - } - /// Finalize a purchase result from a ``purchaseResult(_:)``. - /// 购买结果确认 - func finalizePurchaseResult(_ result: VerificationResult) async throws { - let transaction = try result.verify() - await transaction.finish() - } - /// 获得有效的产品交易 - func getValidProductTransations() async throws -> [Transaction] { - var transactions: [Transaction] = [] - for id in productIds { - if let transaction = try await getValidTransaction(for: id) { - transactions.append(transaction) - } - } - return transactions - } - /// 获取某个产品的所有有效交易 - func getValidTransaction(for productId: ProductID) async throws -> Transaction? { - guard let latest = await Transaction.latest(for: productId) else { return nil } - let result: Transaction = try latest.verify() - return result.isValid ? result : nil - } - /// Listen for transaction updates - /// This function is called by the initializer to get transaction updates and attempt to verify them. - func updateTransactionsOnLaunch() -> Task { - return Task.detached(priority: .background) { [weak self] in - for await result in Transaction.updates { - guard let self = self else { - // If self is deallocated, exit the loop - break - } - - // Check if task is cancelled - guard !Task.isCancelled else { break } - - do { - let transaction = try result.verify() - await self.updatePurchaseTransactions(with: transaction) - } catch { - print("🚨 Transaction listener error: \(error.localizedDescription)") - } - } - } - } -} - -private extension VerificationResult where SignedType == Transaction { - @discardableResult - func verify() throws -> Transaction { - switch self { - case .unverified(let transaction, let error): throw StoreServiceError.invalidTransaction(transaction, error) - case .verified(let transaction): return transaction - } - } -} diff --git a/Sources/StoreKitHelper/StoreContext.swift b/Sources/StoreKitHelper/StoreContext.swift deleted file mode 100755 index 52734b8..0000000 --- a/Sources/StoreKitHelper/StoreContext.swift +++ /dev/null @@ -1,151 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book - -import StoreKit - -public class StoreContext: ObservableObject, @unchecked Sendable { - /// 更新 - private var transactionUpdateTask: Task? = nil - /** - 已同步的产品列表。`用于缓存目的` - - 您可以使用此属性来跟踪从 StoreKit 获取的产品集合。 - - 由于 `Product` 不是 `Codable`,此属性无法持久化, 必须在应用启动时重新加载。 - */ - @Persisted(key: key("productIds"), defaultValue: []) - private var persistedProductIds: [String] - // MARK: - 产品列表 ID - /// 产品列表 ID - @Published public internal(set) var productIds: [String] = [] { - willSet { persistedProductIds = newValue } - } - // MARK: - 产品列表 - /// 产品列表 - 更新 ``StoreContext/productIds`` ID,通过 ``StoreContext/updateProducts(_:)`` 更新产品列表 - @Published public var products: [Product] = [] - /** - 购买的产品 ID 列表。`用于缓存目的` - - 您可以使用此属性来跟踪从 StoreKit 获取的已购买产品。 - - 此属性是持久化的,这意味着当 StoreKit 请求失败时, - 您可以将这些 ID 映射到本地的产品表示。 - */ - @Persisted(key: key("purchasedProductIds"), defaultValue: []) private var persistedPurchasedProductIds: [String] - // MARK: - 已购买产品 ID - /// 已购买的产品ID,限制 id 只能在模块中修改 - @Published public internal(set) var purchasedProductIds: [String] = [] { - willSet { persistedPurchasedProductIds = newValue } - } - /// Purchase transactions, simultaneously updates purchased product IDs - public var purchaseTransactions: [Transaction] = [] { - didSet { - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.purchasedProductIds = self.purchaseTransactions.map { $0.productID } - } - } - } - - /// 弹出 PopUp 显示产品支付界面 - /// 是否显示购买弹窗 - @Published public var isShowingPurchasePopup: Bool = false - /// ``StoreContext/init(productIds:)`` - public convenience init(products: [Product]) { - self.init(productIds: products.map { $0.id }) - } - public init(productIds: [ProductID] = []) { - /// Product IDs (persistence logic) - self.productIds = productIds.count > 0 ? productIds : persistedProductIds - /// Purchased product IDs (persistence logic) - self.purchasedProductIds = persistedPurchasedProductIds - transactionUpdateTask = updateTransactionsOnLaunch() - Task { [weak self] in - guard let self = self else { return } - _ = await self.checkReceipt() - try await self.syncStoreData() - } - } - deinit { - transactionUpdateTask?.cancel() - } -} - -@MainActor public extension StoreContext { - // MARK: - 恢复购买 - /// 恢复购买 - func restorePurchases() async throws { - // 同步应用内购买信息 - try await AppStore.sync() - try await updatePurchases() - } - // MARK: - 同步存储数据 - /// 同步存储数据 - func syncStoreData() async throws { - let products = try await getProducts() - /// 可能网络问题导致数据没有获取,清空本地历史购买记录 - if products.count > 0 { - updateProducts(products) - } - /// 更新购买信息 - try await updatePurchases() - } - // MARK: - 更新购买信息 - /// 更新购买信息 - func updatePurchases() async throws { - let transactions = try await getValidProductTransations() - updatePurchaseTransactions(transactions) - } - // MARK: - 更新产品 - /// 更新产品 - func updateProducts(_ products: [Product]) { - let productIdSet = Set(productIds) - self.products = products.filter { productIdSet.contains($0.id) } - .sorted { - if let index1 = productIds.firstIndex(of: $0.id), - let index2 = productIds.firstIndex(of: $1.id) { - return index1 < index2 - } - return false - } - } - /// 更新购买交易 - 多条数据 - func updatePurchaseTransactions(_ transactions: [Transaction]) { - purchaseTransactions = transactions - } - /// 更新交易记录 - 1条 - func updatePurchaseTransactions(with transaction: Transaction) { - var transactions = purchaseTransactions.filter { - $0.productID != transaction.productID - } - transactions.append(transaction) - purchaseTransactions = transactions - } -} - -extension StoreContext { - static func key(_ name: String) -> String { "com.wangchujiang.storekit.help.\(name)" } -} - -/// This property wrapper automatically persists a new value to user defaults. -@propertyWrapper struct Persisted { - init(key: String, store: UserDefaults = .standard, defaultValue: T) { - self.key = key - self.store = store - self.defaultValue = defaultValue - } - private let key: String - private let store: UserDefaults - private let defaultValue: T - var wrappedValue: T { - get { - guard let data = store.data(forKey: key) else { return defaultValue } - let value = try? JSONDecoder().decode(T.self, from: data) - return value ?? defaultValue - } - set { - let data = try? JSONEncoder().encode(newValue) - store.set(data, forKey: key) - } - } -} diff --git a/Sources/StoreKitHelper/StoreKitError.swift b/Sources/StoreKitHelper/StoreKitError.swift new file mode 100644 index 0000000..1a4167a --- /dev/null +++ b/Sources/StoreKitHelper/StoreKitError.swift @@ -0,0 +1,63 @@ +// +// StoreKitError.swift +// StoreKitHelper +// +// Created by wong on 12/29/25. +// + +import Foundation + +/// StoreKit 错误类型 +public enum StoreKitError: Error, LocalizedError, Equatable { + case productLoadFailed(Error) + case purchaseFailed(Error) + case restoreFailed(Error) + case verificationFailed + case networkError(Error) + case userCancelled + case purchasePending + case unknownError(String) + + // 扩展 Equatable 协议,定义具体比较 + public static func ==(lhs: StoreKitError, rhs: StoreKitError) -> Bool { + switch (lhs, rhs) { + case (.productLoadFailed(let lhsError), .productLoadFailed(let rhsError)), + (.purchaseFailed(let lhsError), .purchaseFailed(let rhsError)), + (.networkError(let lhsError), .networkError(let rhsError)): + // 比较内部的 Error 类型(可以根据需要实现具体的比较逻辑) + return (lhsError as NSError).isEqual(rhsError as NSError) + + case (.verificationFailed, .verificationFailed), + (.userCancelled, .userCancelled), + (.purchasePending, .purchasePending): + return true + + case (.unknownError(let lhsMessage), .unknownError(let rhsMessage)): + return lhsMessage == rhsMessage + + default: + return false + } + } + + public func description(locale: Locale) -> String { + switch self { + case .productLoadFailed(let error): + return String.localizedString(key: "product_load_failed", locale: locale, error.localizedDescription) + case .purchaseFailed(let error): + return String.localizedString(key: "purchase_failed_with_error", locale: locale, error.localizedDescription) + case .restoreFailed(let error): + return String.localizedString(key: "restore_failed_with_error", locale: locale, error.localizedDescription) + case .verificationFailed: + return String.localizedString(key: "verification_failed", locale: locale) + case .networkError(let error): + return String.localizedString(key: "network_error", locale: locale, error.localizedDescription) + case .userCancelled: + return String.localizedString(key: "user_cancelled", locale: locale) + case .purchasePending: + return String.localizedString(key: "purchase_pending", locale: locale) + case .unknownError(let message): + return String.localizedString(key: "unknown_error", locale: locale) + } + } +} diff --git a/Sources/StoreKitHelper/StoreKitHelper.swift b/Sources/StoreKitHelper/StoreKitHelper.swift new file mode 100644 index 0000000..fbe26bc --- /dev/null +++ b/Sources/StoreKitHelper/StoreKitHelper.swift @@ -0,0 +1,253 @@ +import Foundation +import SwiftUI +import StoreKit + +// MARK: - InAppProduct Protocol + +/// 定义的产品 ID 这是`固定`的 +public typealias ProductID = String + +/// 协议,用于定义应用内购买产品 +public protocol InAppProduct: CaseIterable, Identifiable where ID == ProductID { + /// 产品标识符 + var id: ProductID { get } +} + +// MARK: - StoreContext + +/// StoreKit 上下文,用于管理应用内购买状态 +@MainActor +public final class StoreContext: ObservableObject { + // MARK: - Published Properties + /// 产品列表 + @Published public private(set) var products: [Product] = [] + /// 购买的产品标识符集合 + @Published public private(set) var purchasedProductIDs: Set = [] + /// 是否正在加载 + @Published public private(set) var isLoading = false + /// 错误信息 + @Published public private(set) var storeError: StoreKitError? + /// 弹出 PopUp 显示产品支付界面 + /// 是否显示购买弹窗 + @Published public var isShowingPurchasePopup: Bool = false + + // MARK: - Computed Properties + + /// 用户是否没有购买任何产品 + public var hasNotPurchased: Bool { + purchasedProductIDs.isEmpty + } + + /// 用户是否已购买任何产品 + public var hasPurchased: Bool { + !purchasedProductIDs.isEmpty + } + public let productIDs: [String] + + // MARK: - Private Properties + + private var transactionListener: Task? + + // MARK: - Initialization + /// 初始化 StoreContext + /// - Parameter products: 产品列表 + /// ``StoreContext/init(productIds:)`` + public convenience init(products: [T]) { + self.init(productIds: products.map { $0.id }) + } + /// 初始化 StoreContext + /// - Parameter productIds: 产品 ID 列表 + public init(productIds: [ProductID] = []) { + self.productIDs = productIds + // 开始监听交易更新 + startTransactionListener() + + // 加载产品和当前购买状态 + Task { + await loadProducts() + await updatePurchasedProducts() + } + } + + deinit { + transactionListener?.cancel() + } + + // MARK: 购买产品 + /// - Parameter product: 要购买的产品 + public func purchase(_ product: Product) async { + do { + let result = try await product.purchase() + + switch result { + case .success(let verificationResult): + if let transaction = checkVerified(verificationResult) { + // 更新购买状态 + purchasedProductIDs.insert(transaction.productID) + // 完成交易 + await transaction.finish() + + storeError = nil + } else { + storeError = .verificationFailed + } + case .userCancelled: + storeError = .userCancelled + case .pending: + storeError = .purchasePending + @unknown default: + storeError = .unknownError("Unknown purchase result") + } + } catch { + storeError = .purchaseFailed(error) + } + } + + // MARK: 恢复购买 + public func restorePurchases() async { + isLoading = true + storeError = nil + + do { + // 同步 App Store 状态 + let result = try await AppStore.sync() + print("AppStore.sync:", result) + // 更新购买状态 + await updatePurchasedProducts() + + // 清除错误信息 + await MainActor.run { + self.isLoading = false + self.storeError = nil + } + + } catch { + await MainActor.run { + self.isLoading = false + self.storeError = .restoreFailed(error) + } + } + } + + /// 清除当前错误 + public func clearError() { + storeError = nil + } + + /// 检查是否购买了指定产品 + /// - Parameter productID: 产品标识符 + /// - Returns: 是否已购买 + public func isPurchased(_ productID: ProductID) -> Bool { + purchasedProductIDs.contains(productID) + } + + /// 检查是否购买了指定产品 + /// - Parameter product: 产品 + /// - Returns: 是否已购买 + public func isPurchased(_ product: T) -> Bool { + purchasedProductIDs.contains(product.id) + } + + /// 根据产品ID查找产品 + /// - Parameter productID: 产品标识符 + /// - Returns: 产品对象 + public func product(for productID: ProductID) -> Product? { + products.first { $0.id == productID } + } + + /// 根据产品查找Product对象 + /// - Parameter product: 产品 + /// - Returns: Product对象 + public func product(for product: T) -> Product? { + products.first { $0.id == product.id } + } + + /// 根据传递进来的 `ID` 进行排序 + public func productsSorted() -> [Product] { + products.sorted { + if let index1 = productIDs.firstIndex(of: $0.id), + let index2 = productIDs.firstIndex(of: $1.id) { + return index1 < index2 + } + return false + } + } + + // MARK: - Private Methods + /// 开始监听交易更新 + private func startTransactionListener() { + transactionListener = Task.detached { + for await verificationResult in StoreKit.Transaction.updates { + await self.handleTransaction(verificationResult) + } + } + } + + /// 处理交易 + /// - Parameter verificationResult: 交易验证结果 + private func handleTransaction(_ verificationResult: VerificationResult) async { + if let transaction = checkVerified(verificationResult) { + // 更新购买状态 + purchasedProductIDs.insert(transaction.productID) + // 完成交易 + await transaction.finish() + } + } + + // MARK: 验证交易 + /// - Parameter result: 验证结果 + /// - Returns: 验证通过的交易 + private func checkVerified(_ result: VerificationResult) -> T? { + switch result { + case .unverified: + return nil + case .verified(let safe): + return safe + } + } + + // MARK: 加载产品列表 + /// 加载产品列表 + private func loadProducts() async { + isLoading = true + do { + let storeProducts = try await Product.products(for: productIDs) + await MainActor.run { + self.products = storeProducts.sorted { $0.price < $1.price } + self.isLoading = false + self.storeError = nil + } + } catch { + await MainActor.run { + self.storeError = .productLoadFailed(error) + self.isLoading = false + } + } + } + + /// 更新已购买的产品 + private func updatePurchasedProducts() async { + var purchasedIDs: Set = [] + /// `currentEntitlements` 会在本地缓存 + for await verificationResult in StoreKit.Transaction.currentEntitlements { + if let transaction = checkVerified(verificationResult), + transaction.revocationDate == nil { + // 检查订阅是否过期 + if let expirationDate = transaction.expirationDate { + // 对于订阅产品,检查是否还未过期 + if expirationDate > Date() { + purchasedIDs.insert(transaction.productID) + } + } else { + // 对于非订阅产品(如一次性购买),没有过期日期 + purchasedIDs.insert(transaction.productID) + } + } + } + + await MainActor.run { + self.purchasedProductIDs = purchasedIDs + } + } +} + diff --git a/Sources/StoreKitHelper/StoreServiceError.swift b/Sources/StoreKitHelper/StoreServiceError.swift deleted file mode 100755 index d71e442..0000000 --- a/Sources/StoreKitHelper/StoreServiceError.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// StoreServiceError.swift -// StoreKitHelper -// -// Created by 王楚江 on 2025/3/4. -// - -import StoreKit - -/// 此枚举定义了与商店服务相关的错误。 -public enum StoreServiceError: Error { - - /// 当交易无法验证时,会抛出此错误。 - case invalidTransaction(Transaction, VerificationResult.VerificationError) - - /// 当平台不支持购买时,会抛出此错误。 - case unsupportedPlatform(_ message: String) -} - -extension StoreServiceError: LocalizedError { - public var errorDescription: String? { - switch self { - case .invalidTransaction(_, let verificationError): - return "Transaction verification failed: \(verificationError.localizedDescription)" - case .unsupportedPlatform(let message): - return "Unsupported platform: \(message)" - } - } -} diff --git a/Sources/StoreKitHelper/ValidatableTransaction.swift b/Sources/StoreKitHelper/ValidatableTransaction.swift deleted file mode 100755 index 7093398..0000000 --- a/Sources/StoreKitHelper/ValidatableTransaction.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// ValidatableTransaction.swift -// StoreKitHelper -// -// Created by 王楚江 on 2025/3/4. -// - -import StoreKit - -/// 任何可以被验证的交易都可以实现此协议。 -/// -/// 有效的交易没有撤销日期,并且过期日期尚未过期。 -public protocol ValidatableTransaction { - /// 交易的过期日期(如果有)。 - var expirationDate: Date? { get } - /// 交易的撤销日期(如果有)。 - var revocationDate: Date? { get } -} - -extension Transaction: ValidatableTransaction {} - -public extension ValidatableTransaction { - /// 交易是否有效。 - /// - /// 有效的交易没有撤销日期,并且没有已过期的过期日期。 - var isValid: Bool { - if revocationDate != nil { return false } - guard let date = expirationDate else { return true } - return date > Date() - } -} diff --git a/Sources/StoreKitHelper/Views/ProductsContentWrapper.swift b/Sources/StoreKitHelper/Views/ProductsContentWrapper.swift deleted file mode 100644 index 19dae18..0000000 --- a/Sources/StoreKitHelper/Views/ProductsContentWrapper.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// ContentWrapper.swift -// StoreKitHelper -// -// Created by Kenny on 2025/4/4. -// - -import SwiftUI - -struct ViewHeightKey: PreferenceKey { - typealias Value = CGFloat - nonisolated(unsafe) static var defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = max(value, nextValue()) - } -} diff --git a/Sources/StoreKitHelper/Views/ProductsLoadList.swift b/Sources/StoreKitHelper/Views/ProductsLoadList.swift deleted file mode 100644 index 691d7e9..0000000 --- a/Sources/StoreKitHelper/Views/ProductsLoadList.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// ProductsLoadList.swift -// StoreKitHelper -// -// Created by wong on 3/28/25. -// - -import SwiftUI -import StoreKit - -/// 用户更新产品列表 -public struct ProductsLoadList: View { - @Environment(\.popupDismissHandle) private var popupDismissHandle - @EnvironmentObject var store: StoreContext - @Binding var loading: ProductsLoadingStatus - @State var products: [Product] = [] - @State var error: StoreKitError? = nil - public init(loading: Binding, @ViewBuilder content: @escaping () -> Content) { - self._loading = loading - self.content = content - } - var content: () -> Content - - @State private var viewHeight: CGFloat? = nil - public var body: some View { - ZStack { - if loading == .unavailable { - ProductsUnavailableView(error: $error) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.background.opacity(0.73)) - .padding(8) - } else if store.products.count > 0 { - VStack(spacing: 0) { - content() - } - } - } - .overlay(content: { - if loading == .loading { - VStack { - ProgressView().controlSize(.small) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.background.opacity(0.73)) - } - }) - .background(GeometryReader { geometry in - Color.clear.preference(key: ViewHeightKey.self, value: geometry.size.height) - }) - .onPreferenceChange(ViewHeightKey.self) { newHeight in - DispatchQueue.main.async { - self.viewHeight = newHeight - } - } - .frame(minHeight: viewHeight) - .onChange(of: products, initial: false, { old, val in - if products.count > 0 { - let productIdSet = Set(store.productIds) - /// 根据 id 进行排序 - store.products = products.filter { productIdSet.contains($0.id) } - .sorted { - if let index1 = store.productIds.firstIndex(of: $0.id), - let index2 = store.productIds.firstIndex(of: $1.id) { - return index1 < index2 - } - return false - } - } - }) - .padding(6) - .onAppear() { - loading = .loading - error = nil - Task { - do { - let products = try await store.getProducts() - if products.count == 0, store.products.count == 0 { - loading = .unavailable - return - } else if products.count > 0 { - self.products = products - } - loading = .complete - } catch { - loading = .unavailable - self.error = error as? StoreKitError - } - } - } - } -} diff --git a/Sources/StoreKitHelper/Views/ProductsUnavailableView.swift b/Sources/StoreKitHelper/Views/ProductsUnavailableView.swift deleted file mode 100644 index 97daea8..0000000 --- a/Sources/StoreKitHelper/Views/ProductsUnavailableView.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// ProductsUnavailableView.swift -// StoreKitHelper -// -// Created by wong on 3/28/25. -// - -import SwiftUI -import StoreKit - -public struct ProductsUnavailableView: View { - @Environment(\.locale) var locale - @Binding var error: StoreKitError? - public init(error: Binding) { - _error = error - } - public var body: some View { - VStack(spacing: 6) { - Text("store_unavailable".localized(locale: locale)).font(.system(size: 16)) - VStack(alignment: .leading, spacing: 6) { - if #available(iOS 17.0, *) { - Text("no_in_app_purchases".localized(locale: locale)) - .foregroundStyle(Color.secondary).fontWeight(.thin) - } else { - Text("no_in_app_purchases".localized(locale: locale)) - .fontWeight(.thin) - } - if let error = error { - ErrorMessage(for: error) - } - } - } - .lineLimit(nil) // 允许多行显示 - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - } - func restartApp() { -#if os(macOS) - let url = URL(fileURLWithPath: Bundle.main.resourcePath!) - let path = url.deletingLastPathComponent().deletingLastPathComponent().absoluteString - let task = Process() - task.launchPath = "/usr/bin/open" - task.arguments = [path] - task.launch() -#endif - exit(0) - } - private func ErrorMessage(for error: StoreKitError) -> some View { - switch error { - case .networkError(_): - return AnyView( - Text(.init("network_connection_check".localized(locale: locale))) - .foregroundStyle(Color.yellow).fontWeight(.thin) - .environment(\.openURL, OpenURLAction { url in - restartApp() - return .handled - }) - ) - case .notAvailableInStorefront: - return AnyView(Text("\(error.localizedDescription)").foregroundStyle(Color.yellow).fontWeight(.thin)) - case .unknown: - return AnyView(Text("\(error.localizedDescription)").foregroundStyle(Color.yellow).fontWeight(.thin)) - default: - return AnyView(Text("\(error.localizedDescription)")) - } - } -} diff --git a/Sources/StoreKitHelper/Views/RestorePurchasesButton.swift b/Sources/StoreKitHelper/Views/RestorePurchasesButton.swift deleted file mode 100644 index e94f7f5..0000000 --- a/Sources/StoreKitHelper/Views/RestorePurchasesButton.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// RestorePurchasesButtonView.swift -// StoreKitHelper -// -// Created by wong on 3/28/25. -// - -import SwiftUI - -// MARK: 恢复购买 -/// 恢复购买 -struct RestorePurchasesButtonView: View { - @Environment(\.locale) var locale - @Environment(\.popupDismissHandle) private var popupDismissHandle - @EnvironmentObject var store: StoreContext - @Binding var restoringPurchase: Bool - var body: some View { - Button(action: { - Task { - restoringPurchase = true - do { - try await store.restorePurchases() - restoringPurchase = false - if store.purchasedProductIds.count > 0 { - popupDismissHandle?() - } else { - Utils.alert(title: "no_purchase_available".localized(locale: locale), message: "") - } - } catch { - restoringPurchase = false - Utils.alert(title: "restore_purchases_failed".localized(locale: locale), message: error.localizedDescription) - } - } - }, label: { - HStack { - if restoringPurchase { - ProgressView().controlSize(.mini) - } - Text("restore_purchases".localized(locale: locale)) - } - }) - #if os(macOS) - .buttonStyle(.link) - #endif - .disabled(restoringPurchase) - } -} diff --git a/Sources/StoreKitHelper/Views/StoreKitHelperSelectionView.swift b/Sources/StoreKitHelper/Views/StoreKitHelperSelectionView.swift index b716926..d2bc192 100644 --- a/Sources/StoreKitHelper/Views/StoreKitHelperSelectionView.swift +++ b/Sources/StoreKitHelper/Views/StoreKitHelperSelectionView.swift @@ -1,8 +1,8 @@ // -// StoreKitHelperSelectView.swift +// StoreKitHelperSelectionView.swift // StoreKitHelper // -// Created by wong on 3/28/25. +// Created by wong on 12/29/25. // import SwiftUI @@ -10,25 +10,22 @@ import StoreKit // MARK: - 选择商品付费界面 public struct StoreKitHelperSelectionView: View { - @EnvironmentObject var store: StoreContext @Environment(\.pricingContent) private var pricingContent - @ObservedObject var viewModel = ProductsListViewModel() + @Environment(\.popupDismissHandle) private var popupDismissHandle + @Environment(\.locale) var locale + @EnvironmentObject var store: StoreContext + /// 恢复购买中.... + @State var restoringPurchase: Bool = false + /// 默认选择的产品 ID + var defaultSelectedProductId: String? = nil /// 正在`购买`中 @State var buyingProductID: String? = nil /// 选中的产品ID @State var selectedProductID: String? = nil - /// `产品`正在加载中... - @State var loadingProducts: ProductsLoadingStatus = .preparing - /// 恢复购买中.... - @State var restoringPurchase: Bool = false var title: String? = nil - /// 默认选择的产品 ID - var defaultSelectedProductId: String? = nil public init(title: String? = nil, defaultSelectedProductId: String? = nil) { self.title = title - if let defaultSelectedProductId { - self.defaultSelectedProductId = defaultSelectedProductId - } + self.selectedProductID = defaultSelectedProductId } public var body: some View { VStack(spacing: 0) { @@ -42,86 +39,15 @@ public struct StoreKitHelperSelectionView: View { .padding(.bottom, 12) Divider() } - ProductsLoadList(loading: $loadingProducts) { - ProductsListView(selectedProductID: $selectedProductID, buyingProductID: $buyingProductID) - .filteredProducts() { productID, product in - if let filteredProducts = viewModel.filteredProducts { - return filteredProducts(productID, product) - } - return true - } - .disabled(restoringPurchase) - } - Divider() - VStack { - HStack { - PurchaseButtonView( - selectedProductID: $selectedProductID, - buyingProductID: $buyingProductID, - loading: $loadingProducts - ) - RestorePurchasesButtonView(restoringPurchase: $restoringPurchase).disabled(buyingProductID != nil) - } - .disabled(buyingProductID != nil || loadingProducts == .loading) - } - .padding(.trailing, 6) - .padding(.vertical, 10) - .disabled(restoringPurchase) - TermsOfServiceView() - .padding(.bottom, 8) -#if os(macOS) - .buttonStyle(.link) -#endif - } - } - /** - Filter the product list to display products based on product IDs - - ```swift - StoreKitHelperSelectionView() - .filteredProducts() { productID, product in - return true - } - ``` - */ - public func filteredProducts(_ filtered: ((String, Product) -> Bool)?) -> StoreKitHelperSelectionView { - viewModel.filteredProducts = filtered - return self - } -} - -// MARK: - 产品列表 -fileprivate struct ProductsListView: View { - @EnvironmentObject var store: StoreContext - @ObservedObject var viewModel = ProductsListViewModel() - @Binding var selectedProductID: ProductID? - @Binding var buyingProductID: String? - var defaultSelectedProductId: String? = nil - var body: some View { - Group { - ForEach(store.products) { product in - let hasPurchased = store.isProductPurchased(product) - let unit = product.subscription?.subscriptionPeriod.unit - let period = product.subscription?.subscriptionPeriod - let isBuying = buyingProductID == product.id - if let filteredProducts = viewModel.filteredProducts { - let shouldDisplay = filteredProducts(product.id, product) - if shouldDisplay == true { - ProductListLabelView( - selectedProductId: $selectedProductID, - productId: product.id, - displayPrice: product.displayPrice, - displayName: product.displayName, - description: product.description, - hasPurchased: hasPurchased, - isBuying: isBuying, - period: period, - unit: unit - ) - .disabled(buyingProductID != nil || isDisabled(product: product)) - } - } else { - ProductListLabelView( + + ProductsLoad { + let products = store.productsSorted() + ForEach(products, id: \.id) { product in + let unit = product.subscription?.subscriptionPeriod.unit + let period = product.subscription?.subscriptionPeriod + let hasPurchased = store.isPurchased(product.id) + let isBuying = buyingProductID == product.id + ProductsListLabelView( selectedProductId: $selectedProductID, productId: product.id, displayPrice: product.displayPrice, @@ -132,102 +58,73 @@ fileprivate struct ProductsListView: View { period: period, unit: unit ) - .disabled(buyingProductID != nil || isDisabled(product: product)) + .disabled(buyingProductID != nil) } } + .padding(6) + + Divider() + + VStack { + HStack { + Button(action: { + guard let product = store.products.first(where: { $0.id == selectedProductID }) else { + return + } + purchase(product: product) + }, label: { + HStack { + if buyingProductID != nil { + ProgressView().controlSize(.small) + } else { + Image(systemName: "cart").font(.system(size: 12)) + } + Text("purchase", bundle: .module) + } + .glassEffectButton(color: Color.accentColor) + }) + .glassButtonStyle() + .tint(.accentColor) + RestorePurchasesButton(restoringPurchase: $restoringPurchase) + } + .disabled(buyingProductID != nil || store.isLoading) + } + .padding(.trailing, 6) + .padding(.vertical, 10) } .onAppear() { - selectedProductID = defaultSelectedProductId ?? store.productIds.first ?? "" + selectedProductID = defaultSelectedProductId ?? store.productIDs.first ?? "" } - } - /// 有购买,禁用`订阅`,`非消耗型`,不禁用`消耗型` - func isDisabled(product: Product) -> Bool { - guard store.purchasedProductIds.count > 0 else { return false } - guard store.purchasedProductIds.contains(product.id) else { - return true - } - /// 有付费产品 - let hasPurchased = store.purchasedProductIds.count > 0 - if hasPurchased == false { - return false - } - /// 自动订阅 - if product.type == Product.ProductType.autoRenewable { - return true - } - /// 订阅 - if product.type == Product.ProductType.nonRenewable { - return true - } - /// 不可消耗的产品 - if product.type == Product.ProductType.nonConsumable { - return true - } - return false - } - - public func filteredProducts(_ filtered: ((String, Product) -> Bool)?) -> ProductsListView { - viewModel.filteredProducts = filtered - return self - } -} - -// MARK: - 点击购买按钮 -/// 点击购买按钮 -struct PurchaseButtonView: View { - @Environment(\.locale) var locale - @Environment(\.popupDismissHandle) private var popupDismissHandle - @EnvironmentObject var store: StoreContext - @Binding var selectedProductID: ProductID? - @Binding var buyingProductID: String? - @Binding var loading: ProductsLoadingStatus - var body: some View { - Button(action: { - guard let product = store.products.first(where: { $0.id == selectedProductID }) else { - return + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 0) { + TermsOfServiceView() +#if os(macOS) + .buttonStyle(.link) +#endif + .padding(.top, 0) + .padding(.bottom, 8) } - purchase(product: product) - }, label: { - HStack { - if buyingProductID != nil { - ProgressView().controlSize(.small) - } else { - Image(systemName: "cart").font(.system(size: 12)) - } - Text("purchase".localized(locale: locale)) - } - }) - .buttonStyle(.borderedProminent) - .tint(.accentColor) + } } func purchase(product: Product) { + let purchaseFailed = String.localizedString(key: "purchase_failed", locale: locale) Task { buyingProductID = product.id do { - let (_, transaction) = try await store.purchase(product) - if let transaction { - await transaction.finish() - } + try await store.purchase(product) buyingProductID = nil - if let transaction { - store.updatePurchaseTransactions(with: transaction) - } else { - try await store.updatePurchases() - } - if store.isProductPurchased(product) == true { + if store.isPurchased(product.id) == true { popupDismissHandle?() } } catch { buyingProductID = nil - Utils.alert(title: "purchase_failed".localized(locale: locale), message: error.localizedDescription) + NotifyAlert.alert(title: purchaseFailed, message: error.localizedDescription) } } } } -// MARK: - 产品详情 -/// 产品详情 -struct ProductListLabelView: View { +private struct ProductsListLabelView: View { @Binding var selectedProductId: ProductID? @State var hovering: Bool = false var productId: ProductID @@ -245,12 +142,12 @@ struct ProductListLabelView: View { }, set: { _ in selectedProductId = productId }) - Toggle(isOn: individual, label: { - HStack { + + Toggle(isOn: individual) { + HStack(alignment: .center) { VStack(alignment: .leading) { Text(displayName) - Text(description) - .foregroundStyle(.secondary).font(.system(size: 12)) + Text(description).foregroundStyle(.secondary).font(.system(size: 12)) .lineLimit(nil) // 允许多行显示 .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) @@ -275,12 +172,8 @@ struct ProductListLabelView: View { Text("\(displayPrice)").font(.system(size: 12)) } } - .foregroundStyle(hovering == true ? Color.white : Color.accentColor) } - .frame(alignment: .leading) - .disabled(hasPurchased) - .contentShape(Rectangle()) - }) + } .padding(.horizontal, 6) .padding(.vertical, 6) .background( @@ -319,7 +212,7 @@ private struct HeaderView: View { .padding(.top) } #endif - Text(title ?? "unlock_premium".localized(locale: locale)).font(.system(size: 14, weight: .bold)) + Text(title != nil ? LocalizedStringKey(title ?? "") : "unlock_premium", bundle: .module).font(.system(size: 14, weight: .bold)) Spacer() if let popupDismissHandle { Button(action: { diff --git a/Sources/StoreKitHelper/Views/StoreKitHelperView.swift b/Sources/StoreKitHelper/Views/StoreKitHelperView.swift index ee07e8b..7e80ad7 100755 --- a/Sources/StoreKitHelper/Views/StoreKitHelperView.swift +++ b/Sources/StoreKitHelper/Views/StoreKitHelperView.swift @@ -2,68 +2,45 @@ // StoreKitHelperView.swift // StoreKitHelper // -// Created by 王楚江 on 2025/3/4. +// Created by wong on 12/28/25. // import SwiftUI import StoreKit - -public enum ProductsLoadingStatus { - /// Loading - case loading - /// Preparing to load - case preparing - /// Completed loading - case complete - /// Unavailable - case unavailable -} - // MARK: - 默认付费界面 public struct StoreKitHelperView: View { - @Environment(\.pricingContent) private var pricingContent + @Environment(\.locale) var locale @EnvironmentObject var store: StoreContext - @ObservedObject var viewModel = ProductsListViewModel() - /// 正在`购买`中 - @State var buyingProductID: String? = nil - /// `产品`正在加载中... - @State var loadingProducts: ProductsLoadingStatus = .preparing + @Environment(\.pricingContent) private var pricingContent /// 恢复购买中.... @State var restoringPurchase: Bool = false + /// 正在`购买`中 + @State var buyingProductID: String? = nil public init() {} public var body: some View { VStack(spacing: 0) { HeaderView() - VStack(alignment: .leading, spacing: 6) { - pricingContent?() - } - .padding(.top, 12) - .padding(.bottom, 12) - Divider() - ProductsLoadList(loading: $loadingProducts) { - ProductsListView(buyingProductID: $buyingProductID, loading: $loadingProducts) - .filteredProducts() { productID, product in - if let filteredProducts = viewModel.filteredProducts { - return filteredProducts(productID, product) - } - return true - } - .disabled(restoringPurchase) - } - if loadingProducts == .complete || loadingProducts == .loading { - Divider() - HStack { - RestorePurchasesButtonView(restoringPurchase: $restoringPurchase).disabled(buyingProductID != nil) + if let pricingContent { + VStack(alignment: .leading, spacing: 6) { + pricingContent() } - .padding(.vertical, 10) + .padding(.top, 12) + .padding(.bottom, 12) } -#if os(iOS) - Spacer() -#endif + Divider() + ProductsLoadList(buyingProductID: $buyingProductID) + .padding(6) + Divider() + HStack { + RestorePurchasesButton(restoringPurchase: $restoringPurchase) + } + .padding(.vertical, 10) + .disabled(buyingProductID != nil || store.isLoading) } .frame(minWidth: 230) .frame(maxWidth: .infinity) + .environment(\.locale, locale) .safeAreaInset(edge: .bottom, spacing: 0) { VStack(spacing: 0) { TermsOfServiceView() @@ -72,117 +49,60 @@ public struct StoreKitHelperView: View { } } } - /** - Filter the product list to display products based on product IDs - - ```swift - StoreKitHelperView() - .filteredProducts() { productID, product in - return true - } - ``` - */ - public func filteredProducts(_ filtered: ((String, Product) -> Bool)?) -> StoreKitHelperView { - viewModel.filteredProducts = filtered - return self - } } -class ProductsListViewModel: ObservableObject { - @Published var filteredProducts: ((String, Product) -> Bool)? -} -// MARK: - Products List -private struct ProductsListView: View { +struct ProductsLoadList: View { @Environment(\.locale) var locale @Environment(\.popupDismissHandle) private var popupDismissHandle @EnvironmentObject var store: StoreContext - @ObservedObject var viewModel = ProductsListViewModel() @Binding var buyingProductID: String? - @Binding var loading: ProductsLoadingStatus - @State var hovering: Bool = false var body: some View { - VStack(spacing: 0) { - ForEach(store.products) { product in + ProductsLoad { + let products = store.productsSorted() + ForEach(products, id: \.id) { product in let unit = product.subscription?.subscriptionPeriod.unit let period = product.subscription?.subscriptionPeriod + let hasPurchased = store.isPurchased(product.id) let isBuying = buyingProductID == product.id - let hasPurchased = store.isProductPurchased(product) - if let filteredProducts = viewModel.filteredProducts { - let shouldDisplay = filteredProducts(product.id, product) - if shouldDisplay == true { - ProductsListLabelView( - isBuying: .constant(isBuying), - productId: product.id, - unit: unit, - period: period, - displayPrice: product.displayPrice, - displayName: product.displayName, - description: product.description, - hasPurchased: hasPurchased - ) { - purchase(product: product) - } - .id(product.id) - .disabled(buyingProductID != nil) - } - } else { - ProductsListLabelView( - isBuying: .constant(isBuying), - productId: product.id, - unit: unit, - period: period, - displayPrice: product.displayPrice, - displayName: product.displayName, - description: product.description, - hasPurchased: hasPurchased - ) { - purchase(product: product) - } - .id(product.id) - .disabled(buyingProductID != nil) + ProductsListLabelView( + isBuying: isBuying, + unit: unit, + period: period, + displayPrice: product.displayPrice, + displayName: product.displayName, + description: product.description, + hasPurchased: hasPurchased, + ) { + purchase(product: product) } + .disabled(buyingProductID != nil) } } } func purchase(product: Product) { + let purchaseFailed = String.localizedString(key: "purchase_failed", locale: locale) Task { buyingProductID = product.id do { - let (_, transaction) = try await store.purchase(product) - if let transaction { - await transaction.finish() - } + try await store.purchase(product) buyingProductID = nil - if let transaction { - store.updatePurchaseTransactions(with: transaction) - } else { - try await store.updatePurchases() - } - if store.isProductPurchased(product) == true { + if store.isPurchased(product.id) == true { popupDismissHandle?() } } catch { buyingProductID = nil - Utils.alert(title: "purchase_failed".localized(locale: locale), message: error.localizedDescription) + NotifyAlert.alert(title: purchaseFailed, message: error.localizedDescription) } } } - - public func filteredProducts(_ filtered: ((String, Product) -> Bool)?) -> ProductsListView { - viewModel.filteredProducts = filtered - return self - } } // MARK: - Products List - item private struct ProductsListLabelView: View { - @EnvironmentObject var store: StoreContext @State var hovering: Bool = false - @Binding var isBuying: Bool - var productId: ProductID + var isBuying: Bool var unit: Product.SubscriptionPeriod.Unit? - /// Subscription Period var period: Product.SubscriptionPeriod? var displayPrice: String var displayName: String @@ -199,10 +119,9 @@ private struct ProductsListLabelView: View { .fixedSize(horizontal: false, vertical: true) } Spacer() - let bind = Binding(get: { isBuying || hovering }, set: { _ in }) Button(action: { purchase() - }, label: { + }) { HStack(spacing: 2) { if isBuying == true { ProgressView().controlSize(.mini) @@ -224,11 +143,11 @@ private struct ProductsListLabelView: View { } .font(.system(size: 12)) .contentShape(Rectangle()) - .foregroundStyle(hovering == true ? Color.white : Color.accentColor) - }) + .foregroundStyle(hovering == true ? Color.secondary : Color.primary) + } .tint(unit == .none ? .blue : .green) - .buttonStyle(CostomPayButtonStyle(isHovered: bind, hasPurchased: hasPurchased)) - .disabled(hasPurchased) + .buttonStyle(CostomPayButtonStyle(isHovered: hovering, hasPurchased: hasPurchased)) + .disabled(hasPurchased || isBuying) #if os(macOS) .onHover { isHovered in if isHovered, hasPurchased { @@ -257,8 +176,9 @@ private struct ProductsListLabelView: View { } } + struct CostomPayButtonStyle: ButtonStyle { - @Binding var isHovered: Bool + var isHovered: Bool var hasPurchased: Bool = false var normalColor: Color = .secondary.opacity(0.25) var hoverColor: Color = Color.accentColor @@ -267,7 +187,6 @@ struct CostomPayButtonStyle: ButtonStyle { .foregroundColor(.secondary) .padding(3) .padding(.horizontal, 3) - .foregroundStyle(isHovered ? Color.primary : Color.secondary) .background( RoundedRectangle(cornerRadius: 12) .fill(isHovered || hasPurchased ? hoverColor.opacity(configuration.isPressed ? 1 : 0.75) : normalColor) @@ -332,17 +251,3 @@ private struct HeaderView: View { } } } - -#if os(iOS) -extension Bundle { - public var icon: UIImage? { - if let icons = infoDictionary?["CFBundleIcons"] as? [String: Any], - let primaryIcon = icons["CFBundlePrimaryIcon"] as? [String: Any], - let iconFiles = primaryIcon["CFBundleIconFiles"] as? [String], - let lastIcon = iconFiles.last { - return UIImage(named: lastIcon) - } - return nil - } -} -#endif diff --git a/Sources/StoreKitHelper/Views/TermsOfServiceView.swift b/Sources/StoreKitHelper/Views/TermsOfServiceView.swift deleted file mode 100644 index 945b68a..0000000 --- a/Sources/StoreKitHelper/Views/TermsOfServiceView.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// TermsOfService.swift -// StoreKitHelper -// -// Created by wong on 3/28/25. -// - -import SwiftUI - -// MARK: 服务条款 & 隐私政策 -struct TermsOfServiceView: View { - @Environment(\.termsOfServiceHandle) private var termsOfServiceHandle - @Environment(\.privacyPolicyHandle) private var privacyPolicyHandle - - @Environment(\.termsOfServiceLabel) private var termsOfServiceLabel - @Environment(\.privacyPolicyLabel) private var privacyPolicyLabel - @Environment(\.locale) var locale - var body: some View { - if termsOfServiceHandle != nil || privacyPolicyHandle != nil { - Divider() - HStack { - if let action = termsOfServiceHandle { - Button(action: action, label: { - let text = termsOfServiceLabel.isEmpty == true ? "terms_of_service".localized(locale: locale) : termsOfServiceLabel - Text(text).frame(maxWidth: .infinity) - }) - .glassEffectButton() - } - if let action = privacyPolicyHandle { - Button(action: action, label: { - let text = privacyPolicyLabel.isEmpty == true ? "privacy_policy".localized(locale: locale) : privacyPolicyLabel - Text(text).frame(maxWidth: .infinity) - }) - .glassEffectButton() - } - } - .padding(.horizontal, 8) - } - } -} - -#Preview { - VStack(spacing: 0) { - TermsOfServiceView() - .termsOfService() { - } - .privacyPolicy() { - } - .padding(.top, 0) - .padding(.bottom, 8) - } - .frame(width: 560) -} diff --git a/Tests/StoreKitHelperTests/Configuration.storekit b/Tests/StoreKitHelperTests/Configuration.storekit new file mode 100644 index 0000000..7a6320e --- /dev/null +++ b/Tests/StoreKitHelperTests/Configuration.storekit @@ -0,0 +1,95 @@ +{ + "appPolicies" : { + "eula" : "", + "policies" : [ + { + "locale" : "en_US", + "policyText" : "", + "policyURL" : "" + } + ] + }, + "identifier" : "8F3F5875", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "0.99", + "familyShareable" : false, + "internalID" : "84CFE130", + "localizations" : [ + { + "description" : "Lifetime to unlock all features", + "displayName" : "All Access Lifetime", + "locale" : "en_US" + } + ], + "productID" : "example.lifetime", + "referenceName" : "Example - Lifetime", + "type" : "NonConsumable" + } + ], + "settings" : { + "_askToBuyEnabled" : false, + "_billingGracePeriodEnabled" : false, + "_billingIssuesEnabled" : false, + "_disableDialogs" : false, + "_failTransactionsEnabled" : false, + "_locale" : "en_US", + "_renewalBillingIssuesEnabled" : false, + "_storefront" : "USA", + "_storeKitErrors" : [ + + ], + "_timeRate" : 0 + }, + "subscriptionGroups" : [ + { + "id" : "DD979BE3", + "localizations" : [ + + ], + "name" : "Example Pro", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "0.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "0C48BA22", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "Subscribe monthly to unlock all features", + "displayName" : "All Access Monthly", + "locale" : "en_US" + }, + { + "description" : "按月订阅付费解锁所有功能", + "displayName" : "全功能包月", + "locale" : "zh_Hans" + } + ], + "productID" : "example.monthly", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Example - All Access Monthly", + "subscriptionGroupID" : "DD979BE3", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + } + ] + } + ], + "version" : { + "major" : 4, + "minor" : 0 + } +} diff --git a/Tests/StoreKitHelperTests/StoreKitHelperTests.swift b/Tests/StoreKitHelperTests/StoreKitHelperTests.swift new file mode 100644 index 0000000..7d77f27 --- /dev/null +++ b/Tests/StoreKitHelperTests/StoreKitHelperTests.swift @@ -0,0 +1,56 @@ +import Testing +import StoreKit +import StoreKitTest + +@testable import StoreKitHelper + +// 测试用的产品枚举 +enum TestProduct: String, InAppProduct { + case basic = "test.basic" + case premium = "test.premium" + var id: String { rawValue } +} + +@Test("InAppProduct protocol conformance") +func testInAppProductProtocol() async throws { + // 测试产品枚举是否正确实现了 InAppProduct 协议 + let products = TestProduct.allCases + + #expect(products.count == 2) + #expect(products.contains(.basic)) + #expect(products.contains(.premium)) + + #expect(TestProduct.basic.id == "test.basic") + #expect(TestProduct.premium.id == "test.premium") +} + +@Test("StoreContext initialization") +func testStoreContextInitialization() async throws { + let store = await StoreContext(products: TestProduct.allCases) + + // 测试初始状态 + await MainActor.run { + #expect(store.products.isEmpty) // 初始时产品列表为空(需要从 App Store 加载) + #expect(store.purchasedProductIDs.isEmpty) + #expect(store.hasNotPurchased == true) + #expect(store.hasPurchased == false) + } +} + +@Test("Purchase status check methods") +func testPurchaseStatusMethods() async throws { + let store = await StoreContext(products: TestProduct.allCases) + + await MainActor.run { + // 测试购买状态检查方法 + #expect(store.isPurchased("test.basic") == false) + #expect(store.isPurchased(TestProduct.premium) == false) + // 模拟购买状态(仅在测试中使用) + store._setPurchasedProductIDsForTesting(["test.basic"]) + #expect(store.isPurchased("test.basic") == true) + #expect(store.isPurchased(TestProduct.basic) == true) + #expect(store.isPurchased("test.premium") == false) + #expect(store.hasPurchased == true) + #expect(store.hasNotPurchased == false) + } +}