feat: refactor project.

This commit is contained in:
小弟调调 2025-12-29 16:53:51 +08:00
parent 8367a3c0f8
commit 207de74f7a
53 changed files with 2676 additions and 1302 deletions

24
.github/workflows/tag.yml vendored Normal file
View file

@ -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 }}

View file

@ -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 = "<group>";
};
E0EB56D92F01455C001F6F30 /* ExampleTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
E0EB56FC2F014628001F6F30 /* Exceptions for "ExampleTests" folder in "Example" target */,
);
path = ExampleTests;
sourceTree = "<group>";
};
E0EB56E32F01455C001F6F30 /* ExampleUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = ExampleUITests;
sourceTree = "<group>";
};
/* 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 = "<group>";
};
E0EB56CA2F014559001F6F30 /* Products */ = {
isa = PBXGroup;
children = (
E0EB56C92F014559001F6F30 /* Example.app */,
E0EB56D62F01455C001F6F30 /* ExampleTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
E0EB56F62F0145C5001F6F30 /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
/* 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 */;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E0EB56C82F014559001F6F30"
BuildableName = "Example.app"
BlueprintName = "Example"
ReferencedContainer = "container:Example.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E0EB56D52F01455C001F6F30"
BuildableName = "ExampleTests.xctest"
BlueprintName = "ExampleTests"
ReferencedContainer = "container:Example.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
language = "zh-Hans"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E0EB56C82F014559001F6F30"
BuildableName = "Example.app"
BlueprintName = "Example"
ReferencedContainer = "container:Example.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../ExampleTests/Configuration.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E0EB56C82F014559001F6F30"
BuildableName = "Example.app"
BlueprintName = "Example"
ReferencedContainer = "container:Example.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -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<Lablel: View>: 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"
}
}

View file

@ -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)
}
}
}

View file

@ -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"
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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()
}
}

View file

@ -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 its 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()
}
}
}

View file

@ -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)
}
}

View file

@ -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")
]
),
]
)

148
README.md
View file

@ -1,5 +1,5 @@
<div markdown="1">
<sup>Using <a href="https://wangchujiang.com/#/app" target="_blank">my app</a> is also a way to <a href="https://wangchujiang.com/#/sponsor" target="_blank">support</a> me:</sup>
<sup>Using <a href="https://wangchujiang.com/#/app" target="_blank">my apps</a> is also a way to <a href="https://wangchujiang.com/#/sponsor" target="_blank">support</a> me:</sup>
<br>
<a target="_blank" href="https://apps.apple.com/app/Deskmark/6755948110" title="Deskmark for macOS"><img alt="Deskmark" height="52" width="52" src="https://wangchujiang.com/appicon/deskmark.png"></a>
<a target="_blank" href="https://apps.apple.com/app/Keyzer/6500434773" title="Keyzer for macOS"><img alt="Keyzer" height="52" width="52" src="https://wangchujiang.com/appicon/keyzer.png"></a>
@ -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 dont 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<String>` - 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.

View file

@ -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<String>` - 已购买产品标识符的集合
- `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 许可证授权。

View file

@ -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()

View file

@ -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<Content: View>: 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)
}
}

View file

@ -1,8 +1,8 @@
//
// BuyButton.swift
// PurchasePopupButton.swift
// StoreKitHelper
//
// Created by on 2025/3/4.
// Created by wong on 12/29/25.
//
import SwiftUI

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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<T: View>: 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<T: View>(@ViewBuilder content: @escaping () -> T) -> some View {
return self.environment(\.pricingContent, { AnyView(content()) })
struct PopupDismissHandle: @preconcurrency EnvironmentKey {
@MainActor static let defaultValue: (() -> Void)? = nil
}
//
struct PricingContent<T: View>: 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 = ""
}

View file

@ -0,0 +1,11 @@
//
// Notification.swift
// StoreKitHelper
//
// Created by wong on 12/29/25.
//
import SwiftUI
extension Notification.Name {
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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) }
}
}

View file

@ -1,16 +0,0 @@
//
// Product.swift
// StoreKitHelper
//
// Created by on 2025/3/8.
//
import StoreKit
public extension Product {
/// `ID` AppStore
static func products<T: InAppProduct>(for representations: [T]) async throws -> [Product] {
let ids = representations.map { $0.id }
return try await products(for: ids)
}
}

View file

@ -1,13 +0,0 @@
//
// ProductID.swift
// StoreKitHelper
//
// Created by on 2025/3/8.
//
/// ID ``
public typealias ProductID = String
/// ID
public typealias ProductFetchID = String

View file

@ -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: %@";

View file

@ -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: %@";

View file

@ -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 : %@";

View file

@ -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" = "不明なエラー: %@";

View file

@ -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" = "알 수 없는 오류: %@";

View file

@ -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" = "未知错误: %@";

View file

@ -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" = "未知錯誤: %@";

View file

@ -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)
}
}

View file

@ -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<Transaction>) 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<Void, Never> {
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
}
}
}

View file

@ -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<Void, Never>? = 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<Product: InAppProduct>(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<T: Codable> {
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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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<String> = []
///
@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<Void, Never>?
// MARK: - Initialization
/// StoreContext
/// - Parameter products:
/// ``StoreContext/init(productIds:)``
public convenience init<T: InAppProduct>(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<T: InAppProduct>(_ 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<T: InAppProduct>(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<StoreKit.Transaction>) async {
if let transaction = checkVerified(verificationResult) {
//
purchasedProductIDs.insert(transaction.productID)
//
await transaction.finish()
}
}
// MARK:
/// - Parameter result:
/// - Returns:
private func checkVerified<T>(_ result: VerificationResult<T>) -> 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<String> = []
/// `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
}
}
}

View file

@ -1,29 +0,0 @@
//
// StoreServiceError.swift
// StoreKitHelper
//
// Created by on 2025/3/4.
//
import StoreKit
///
public enum StoreServiceError: Error {
///
case invalidTransaction(Transaction, VerificationResult<Transaction>.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)"
}
}
}

View file

@ -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()
}
}

View file

@ -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())
}
}

View file

@ -1,91 +0,0 @@
//
// ProductsLoadList.swift
// StoreKitHelper
//
// Created by wong on 3/28/25.
//
import SwiftUI
import StoreKit
///
public struct ProductsLoadList<Content: View>: 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<ProductsLoadingStatus>, @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
}
}
}
}
}

View file

@ -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<StoreKitError?>) {
_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)"))
}
}
}

View file

@ -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)
}
}

View file

@ -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: {

View file

@ -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

View file

@ -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)
}

View file

@ -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
}
}

View file

@ -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)
}
}