├── .github └── workflows │ ├── build-xcframework.yml │ ├── build.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── ETDistribution.podspec ├── Example ├── DemoApp.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ └── contents.xcworkspacedata └── DemoApp │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── Constants.swift │ ├── ContentView.swift │ ├── DemoApp-Bridging-Header.h │ ├── DemoApp.entitlements │ ├── DemoAppApp.swift │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ ├── UpdateUtil.swift │ ├── UpdateUtilObjc.h │ └── UpdateUtilObjc.m ├── LICENSE ├── Package.swift ├── README.md ├── Sources ├── Auth.swift ├── ETDistribution.swift ├── JWT │ └── JWTHelper.swift ├── Keychain │ └── KeychainHelper.swift ├── Mach-O │ └── BinaryParser.swift ├── Models │ ├── AuthCodeResponse.swift │ ├── AuthRefreshResponse.swift │ ├── CheckForUpdateParams.swift │ ├── DistributionReleaseInfo.swift │ └── DistributionUpdateCheckResponse.swift ├── Network │ └── URLSession+Distribute.swift ├── UserAction.swift ├── UserDefaults+ETDistribution.swift ├── Utils │ └── Date+Parse.swift └── ViewController │ └── UIViewController+Distribute.swift ├── Tests └── DistributionReleaseInfoTests.swift └── build.sh /.github/workflows/build-xcframework.yml: -------------------------------------------------------------------------------- 1 | name: Build XCFramework 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: macos-14 11 | strategy: 12 | matrix: 13 | xcode-version: [15.4, 16.1] 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | - name: Xcode select ${{ matrix.xcode-version }} 18 | run: sudo xcode-select -s '/Applications/Xcode_${{ matrix.xcode-version }}.app/Contents/Developer' 19 | - name: Get Swift Version 20 | run: | 21 | SWIFT_MAJOR_VERSION=$(swift --version 2>&1 | awk '/Apple Swift version/ { split($7, ver, "."); print ver[1]; exit }') 22 | echo "Swift major version: $SWIFT_MAJOR_VERSION" 23 | echo "SWIFT_VERSION=$SWIFT_MAJOR_VERSION.0" >> "$GITHUB_ENV" 24 | - name: Build xcframework 25 | run: sh build.sh 26 | env: 27 | SWIFT_VERSION: ${{ env.SWIFT_VERSION }} -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request Build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: macos-14 11 | strategy: 12 | matrix: 13 | xcode-version: [15.4, 16.1] 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | - name: Xcode select ${{ matrix.xcode-version }} 18 | run: sudo xcode-select -s '/Applications/Xcode_${{ matrix.xcode-version }}.app/Contents/Developer' 19 | - name: Get Swift Version 20 | run: | 21 | SWIFT_MAJOR_VERSION=$(swift --version 2>&1 | awk '/Apple Swift version/ { split($7, ver, "."); print ver[1]; exit }') 22 | echo "Swift major version: $SWIFT_MAJOR_VERSION" 23 | echo "SWIFT_VERSION=$SWIFT_MAJOR_VERSION.0" >> "$GITHUB_ENV" 24 | - name: Build for iOS Simulator 25 | run: xcodebuild build -scheme ETDistribution -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' SWIFT_TREAT_WARNINGS_AS_ERRORS=YES GCC_TREAT_WARNINGS_AS_ERRORS=YES 26 | - name: Build for iOS 27 | run: xcodebuild build -scheme ETDistribution -sdk iphoneos -destination 'generic/platform=iOS' SWIFT_TREAT_WARNINGS_AS_ERRORS=YES GCC_TREAT_WARNINGS_AS_ERRORS=YES 28 | - name: Build TestApp 29 | run: cd Example && xcodebuild build -scheme DemoApp -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -project DemoApp.xcodeproj SWIFT_VERSION=${{ env.SWIFT_VERSION }} -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release workflow 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | runs-on: macos-14 11 | strategy: 12 | matrix: 13 | xcode-version: [15.4, 16.1] 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | - name: Xcode select ${{ matrix.xcode-version }} 18 | run: sudo xcode-select -s '/Applications/Xcode_${{ matrix.xcode-version }}.app/Contents/Developer' 19 | - name: Get Swift Version 20 | run: | 21 | SWIFT_MAJOR_VERSION=$(swift --version 2>&1 | awk '/Apple Swift version/ { split($7, ver, "."); print ver[1]; exit }') 22 | echo "Swift major version: $SWIFT_MAJOR_VERSION" 23 | echo "SWIFT_MAJOR_VERSION=$SWIFT_MAJOR_VERSION" >> "$GITHUB_ENV" 24 | echo "SWIFT_VERSION=$SWIFT_MAJOR_VERSION.0" >> "$GITHUB_ENV" 25 | - name: Build xcframework 26 | run: sh build.sh 27 | env: 28 | SWIFT_VERSION: ${{ env.SWIFT_VERSION }} 29 | - name: Zip xcframework 30 | run: zip -r ETDistribution_swift_${{ env.SWIFT_MAJOR_VERSION }}.xcframework.zip ETDistribution.xcframework 31 | - name: Upload Artifact 32 | uses: softprops/action-gh-release@v1 33 | if: startsWith(github.ref, 'refs/tags/') 34 | with: 35 | files: | 36 | ETDistribution_swift_${{ env.SWIFT_MAJOR_VERSION }}.xcframework.zip 37 | body: 38 | Release ${{ github.ref }} 39 | Automated release created by GitHub Actions. -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request Build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | test: 10 | runs-on: macos-14 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v2 14 | - name: Xcode select 15 | run: sudo xcode-select -s '/Applications/Xcode_16.1.app/Contents/Developer' 16 | - name: Run tests 17 | run: xcodebuild -scheme 'ETDistribution' -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 16 Pro' test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | **/*.xcodeproj/xcuserdata/ 3 | **/*.xcworkspace/xcuserdata/ 4 | **/*.xcodeproj/xcshareddata/ 5 | .swiftpm 6 | .build 7 | *.xcarchive 8 | *.xcframework -------------------------------------------------------------------------------- /ETDistribution.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | spec.name = 'ETDistribution' 3 | spec.version = '0.2.1' 4 | spec.summary = 'iOS app installer.' 5 | spec.homepage = 'https://github.com/EmergeTools/ETDistribution' 6 | spec.license = { :type => 'MIT', :file => 'LICENSE' } 7 | spec.authors = "Emerge Tools" 8 | spec.source = { :git => 'https://github.com/EmergeTools/ETDistribution.git', :tag => 'v0.2.1' } 9 | spec.platform = :ios, '13.0' 10 | spec.swift_version = '5.10' 11 | spec.source_files = 'Sources/**/*.{swift,h,m}' 12 | end 13 | -------------------------------------------------------------------------------- /Example/DemoApp.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 60; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | F41853D02CB70E47007F7CE8 /* ETDistribution in Frameworks */ = {isa = PBXBuildFile; productRef = F41853CF2CB70E47007F7CE8 /* ETDistribution */; }; 11 | F41853D22CB70EE3007F7CE8 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41853D12CB70EE3007F7CE8 /* Constants.swift */; }; 12 | F41853D42CB70EF0007F7CE8 /* UpdateUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41853D32CB70EF0007F7CE8 /* UpdateUtil.swift */; }; 13 | F41853D62CB70EF8007F7CE8 /* UpdateUtilObjc.m in Sources */ = {isa = PBXBuildFile; fileRef = F41853D52CB70EF8007F7CE8 /* UpdateUtilObjc.m */; }; 14 | F4B700BB2D4295C800D255F3 /* ETDistribution in Embed Frameworks */ = {isa = PBXBuildFile; productRef = F41853CF2CB70E47007F7CE8 /* ETDistribution */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 15 | FA1671C32A5367A800A42DB0 /* DemoAppApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1671C22A5367A800A42DB0 /* DemoAppApp.swift */; }; 16 | FA1671C52A5367A800A42DB0 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1671C42A5367A800A42DB0 /* ContentView.swift */; }; 17 | FA1671C72A5367A800A42DB0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FA1671C62A5367A800A42DB0 /* Assets.xcassets */; }; 18 | FA1671CB2A5367A800A42DB0 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FA1671CA2A5367A800A42DB0 /* Preview Assets.xcassets */; }; 19 | /* End PBXBuildFile section */ 20 | 21 | /* Begin PBXCopyFilesBuildPhase section */ 22 | F4B700BC2D4295C800D255F3 /* Embed Frameworks */ = { 23 | isa = PBXCopyFilesBuildPhase; 24 | buildActionMask = 2147483647; 25 | dstPath = ""; 26 | dstSubfolderSpec = 10; 27 | files = ( 28 | F4B700BB2D4295C800D255F3 /* ETDistribution in Embed Frameworks */, 29 | ); 30 | name = "Embed Frameworks"; 31 | runOnlyForDeploymentPostprocessing = 0; 32 | }; 33 | /* End PBXCopyFilesBuildPhase section */ 34 | 35 | /* Begin PBXFileReference section */ 36 | F41853D12CB70EE3007F7CE8 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 37 | F41853D32CB70EF0007F7CE8 /* UpdateUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateUtil.swift; sourceTree = ""; }; 38 | F41853D52CB70EF8007F7CE8 /* UpdateUtilObjc.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UpdateUtilObjc.m; sourceTree = ""; }; 39 | F41853D72CB70EFA007F7CE8 /* DemoApp-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "DemoApp-Bridging-Header.h"; sourceTree = ""; }; 40 | F41853D82CB70F06007F7CE8 /* UpdateUtilObjc.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UpdateUtilObjc.h; sourceTree = ""; }; 41 | FA1671BF2A5367A800A42DB0 /* DemoApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DemoApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 42 | FA1671C22A5367A800A42DB0 /* DemoAppApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoAppApp.swift; sourceTree = ""; }; 43 | FA1671C42A5367A800A42DB0 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 44 | FA1671C62A5367A800A42DB0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 45 | FA1671C82A5367A800A42DB0 /* DemoApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DemoApp.entitlements; sourceTree = ""; }; 46 | FA1671CA2A5367A800A42DB0 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 47 | FACA23752A55FB970080545A /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; 48 | /* End PBXFileReference section */ 49 | 50 | /* Begin PBXFrameworksBuildPhase section */ 51 | FA1671BC2A5367A800A42DB0 /* Frameworks */ = { 52 | isa = PBXFrameworksBuildPhase; 53 | buildActionMask = 2147483647; 54 | files = ( 55 | F41853D02CB70E47007F7CE8 /* ETDistribution in Frameworks */, 56 | ); 57 | runOnlyForDeploymentPostprocessing = 0; 58 | }; 59 | /* End PBXFrameworksBuildPhase section */ 60 | 61 | /* Begin PBXGroup section */ 62 | FA1671B62A5367A800A42DB0 = { 63 | isa = PBXGroup; 64 | children = ( 65 | FA1671C12A5367A800A42DB0 /* DemoApp */, 66 | FA1671C02A5367A800A42DB0 /* Products */, 67 | FA6E72BD2A54944800448463 /* Frameworks */, 68 | ); 69 | sourceTree = ""; 70 | }; 71 | FA1671C02A5367A800A42DB0 /* Products */ = { 72 | isa = PBXGroup; 73 | children = ( 74 | FA1671BF2A5367A800A42DB0 /* DemoApp.app */, 75 | ); 76 | name = Products; 77 | sourceTree = ""; 78 | }; 79 | FA1671C12A5367A800A42DB0 /* DemoApp */ = { 80 | isa = PBXGroup; 81 | children = ( 82 | FA1671C22A5367A800A42DB0 /* DemoAppApp.swift */, 83 | FA1671C42A5367A800A42DB0 /* ContentView.swift */, 84 | F41853D12CB70EE3007F7CE8 /* Constants.swift */, 85 | F41853D32CB70EF0007F7CE8 /* UpdateUtil.swift */, 86 | F41853D72CB70EFA007F7CE8 /* DemoApp-Bridging-Header.h */, 87 | F41853D82CB70F06007F7CE8 /* UpdateUtilObjc.h */, 88 | F41853D52CB70EF8007F7CE8 /* UpdateUtilObjc.m */, 89 | FA1671C62A5367A800A42DB0 /* Assets.xcassets */, 90 | FA1671C82A5367A800A42DB0 /* DemoApp.entitlements */, 91 | FA1671C92A5367A800A42DB0 /* Preview Content */, 92 | ); 93 | path = DemoApp; 94 | sourceTree = ""; 95 | }; 96 | FA1671C92A5367A800A42DB0 /* Preview Content */ = { 97 | isa = PBXGroup; 98 | children = ( 99 | FA1671CA2A5367A800A42DB0 /* Preview Assets.xcassets */, 100 | ); 101 | path = "Preview Content"; 102 | sourceTree = ""; 103 | }; 104 | FA6E72BD2A54944800448463 /* Frameworks */ = { 105 | isa = PBXGroup; 106 | children = ( 107 | FACA23752A55FB970080545A /* XCTest.framework */, 108 | ); 109 | name = Frameworks; 110 | sourceTree = ""; 111 | }; 112 | /* End PBXGroup section */ 113 | 114 | /* Begin PBXNativeTarget section */ 115 | FA1671BE2A5367A800A42DB0 /* DemoApp */ = { 116 | isa = PBXNativeTarget; 117 | buildConfigurationList = FA1671CE2A5367A800A42DB0 /* Build configuration list for PBXNativeTarget "DemoApp" */; 118 | buildPhases = ( 119 | FA1671BB2A5367A800A42DB0 /* Sources */, 120 | FA1671BC2A5367A800A42DB0 /* Frameworks */, 121 | FA1671BD2A5367A800A42DB0 /* Resources */, 122 | F4B700BC2D4295C800D255F3 /* Embed Frameworks */, 123 | ); 124 | buildRules = ( 125 | ); 126 | dependencies = ( 127 | ); 128 | name = DemoApp; 129 | packageProductDependencies = ( 130 | F41853CF2CB70E47007F7CE8 /* ETDistribution */, 131 | ); 132 | productName = DemoApp; 133 | productReference = FA1671BF2A5367A800A42DB0 /* DemoApp.app */; 134 | productType = "com.apple.product-type.application"; 135 | }; 136 | /* End PBXNativeTarget section */ 137 | 138 | /* Begin PBXProject section */ 139 | FA1671B72A5367A800A42DB0 /* Project object */ = { 140 | isa = PBXProject; 141 | attributes = { 142 | BuildIndependentTargetsInParallel = 1; 143 | LastSwiftUpdateCheck = 1540; 144 | LastUpgradeCheck = 1500; 145 | ORGANIZATIONNAME = "Emerge Tools"; 146 | TargetAttributes = { 147 | FA1671BE2A5367A800A42DB0 = { 148 | CreatedOnToolsVersion = 15.0; 149 | LastSwiftMigration = 1600; 150 | }; 151 | }; 152 | }; 153 | buildConfigurationList = FA1671BA2A5367A800A42DB0 /* Build configuration list for PBXProject "DemoApp" */; 154 | compatibilityVersion = "Xcode 14.0"; 155 | developmentRegion = en; 156 | hasScannedForEncodings = 0; 157 | knownRegions = ( 158 | en, 159 | Base, 160 | ); 161 | mainGroup = FA1671B62A5367A800A42DB0; 162 | packageReferences = ( 163 | F41853CE2CB70E47007F7CE8 /* XCLocalSwiftPackageReference "../../ETDistribution" */, 164 | ); 165 | productRefGroup = FA1671C02A5367A800A42DB0 /* Products */; 166 | projectDirPath = ""; 167 | projectRoot = ""; 168 | targets = ( 169 | FA1671BE2A5367A800A42DB0 /* DemoApp */, 170 | ); 171 | }; 172 | /* End PBXProject section */ 173 | 174 | /* Begin PBXResourcesBuildPhase section */ 175 | FA1671BD2A5367A800A42DB0 /* Resources */ = { 176 | isa = PBXResourcesBuildPhase; 177 | buildActionMask = 2147483647; 178 | files = ( 179 | FA1671CB2A5367A800A42DB0 /* Preview Assets.xcassets in Resources */, 180 | FA1671C72A5367A800A42DB0 /* Assets.xcassets in Resources */, 181 | ); 182 | runOnlyForDeploymentPostprocessing = 0; 183 | }; 184 | /* End PBXResourcesBuildPhase section */ 185 | 186 | /* Begin PBXSourcesBuildPhase section */ 187 | FA1671BB2A5367A800A42DB0 /* Sources */ = { 188 | isa = PBXSourcesBuildPhase; 189 | buildActionMask = 2147483647; 190 | files = ( 191 | F41853D42CB70EF0007F7CE8 /* UpdateUtil.swift in Sources */, 192 | FA1671C52A5367A800A42DB0 /* ContentView.swift in Sources */, 193 | FA1671C32A5367A800A42DB0 /* DemoAppApp.swift in Sources */, 194 | F41853D22CB70EE3007F7CE8 /* Constants.swift in Sources */, 195 | F41853D62CB70EF8007F7CE8 /* UpdateUtilObjc.m in Sources */, 196 | ); 197 | runOnlyForDeploymentPostprocessing = 0; 198 | }; 199 | /* End PBXSourcesBuildPhase section */ 200 | 201 | /* Begin XCBuildConfiguration section */ 202 | FA1671CC2A5367A800A42DB0 /* Debug */ = { 203 | isa = XCBuildConfiguration; 204 | buildSettings = { 205 | ALWAYS_SEARCH_USER_PATHS = NO; 206 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 207 | CLANG_ANALYZER_NONNULL = YES; 208 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 209 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 210 | CLANG_ENABLE_MODULES = YES; 211 | CLANG_ENABLE_OBJC_ARC = YES; 212 | CLANG_ENABLE_OBJC_WEAK = YES; 213 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 214 | CLANG_WARN_BOOL_CONVERSION = YES; 215 | CLANG_WARN_COMMA = YES; 216 | CLANG_WARN_CONSTANT_CONVERSION = YES; 217 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 218 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 219 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 220 | CLANG_WARN_EMPTY_BODY = YES; 221 | CLANG_WARN_ENUM_CONVERSION = YES; 222 | CLANG_WARN_INFINITE_RECURSION = YES; 223 | CLANG_WARN_INT_CONVERSION = YES; 224 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 225 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 226 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 227 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 228 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 229 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 230 | CLANG_WARN_STRICT_PROTOTYPES = YES; 231 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 232 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 233 | CLANG_WARN_UNREACHABLE_CODE = YES; 234 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 235 | COPY_PHASE_STRIP = NO; 236 | DEBUG_INFORMATION_FORMAT = dwarf; 237 | ENABLE_STRICT_OBJC_MSGSEND = YES; 238 | ENABLE_TESTABILITY = YES; 239 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 240 | GCC_C_LANGUAGE_STANDARD = gnu17; 241 | GCC_DYNAMIC_NO_PIC = NO; 242 | GCC_NO_COMMON_BLOCKS = YES; 243 | GCC_OPTIMIZATION_LEVEL = 0; 244 | GCC_PREPROCESSOR_DEFINITIONS = ( 245 | "DEBUG=1", 246 | "$(inherited)", 247 | ); 248 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 249 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 250 | GCC_WARN_UNDECLARED_SELECTOR = YES; 251 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 252 | GCC_WARN_UNUSED_FUNCTION = YES; 253 | GCC_WARN_UNUSED_VARIABLE = YES; 254 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 255 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 256 | MTL_FAST_MATH = YES; 257 | ONLY_ACTIVE_ARCH = YES; 258 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 259 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 260 | }; 261 | name = Debug; 262 | }; 263 | FA1671CD2A5367A800A42DB0 /* Release */ = { 264 | isa = XCBuildConfiguration; 265 | buildSettings = { 266 | ALWAYS_SEARCH_USER_PATHS = NO; 267 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 268 | CLANG_ANALYZER_NONNULL = YES; 269 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 270 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 271 | CLANG_ENABLE_MODULES = YES; 272 | CLANG_ENABLE_OBJC_ARC = YES; 273 | CLANG_ENABLE_OBJC_WEAK = YES; 274 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 275 | CLANG_WARN_BOOL_CONVERSION = YES; 276 | CLANG_WARN_COMMA = YES; 277 | CLANG_WARN_CONSTANT_CONVERSION = YES; 278 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 279 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 280 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 281 | CLANG_WARN_EMPTY_BODY = YES; 282 | CLANG_WARN_ENUM_CONVERSION = YES; 283 | CLANG_WARN_INFINITE_RECURSION = YES; 284 | CLANG_WARN_INT_CONVERSION = YES; 285 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 286 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 287 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 288 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 289 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 290 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 291 | CLANG_WARN_STRICT_PROTOTYPES = YES; 292 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 293 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 294 | CLANG_WARN_UNREACHABLE_CODE = YES; 295 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 296 | COPY_PHASE_STRIP = NO; 297 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 298 | ENABLE_NS_ASSERTIONS = NO; 299 | ENABLE_STRICT_OBJC_MSGSEND = YES; 300 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 301 | GCC_C_LANGUAGE_STANDARD = gnu17; 302 | GCC_NO_COMMON_BLOCKS = YES; 303 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 304 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 305 | GCC_WARN_UNDECLARED_SELECTOR = YES; 306 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 307 | GCC_WARN_UNUSED_FUNCTION = YES; 308 | GCC_WARN_UNUSED_VARIABLE = YES; 309 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 310 | MTL_ENABLE_DEBUG_INFO = NO; 311 | MTL_FAST_MATH = YES; 312 | SWIFT_COMPILATION_MODE = wholemodule; 313 | }; 314 | name = Release; 315 | }; 316 | FA1671CF2A5367A800A42DB0 /* Debug */ = { 317 | isa = XCBuildConfiguration; 318 | buildSettings = { 319 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 320 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 321 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 322 | CLANG_ENABLE_MODULES = YES; 323 | CODE_SIGN_ENTITLEMENTS = DemoApp/DemoApp.entitlements; 324 | CODE_SIGN_STYLE = Automatic; 325 | CURRENT_PROJECT_VERSION = 1; 326 | DEVELOPMENT_ASSET_PATHS = "\"DemoApp/Preview Content\""; 327 | DEVELOPMENT_TEAM = 62J2XHNK9T; 328 | ENABLE_HARDENED_RUNTIME = NO; 329 | ENABLE_PREVIEWS = YES; 330 | GCC_TREAT_WARNINGS_AS_ERRORS = YES; 331 | GENERATE_INFOPLIST_FILE = YES; 332 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 333 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 334 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 335 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 336 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 337 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 338 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 339 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 340 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 341 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 342 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 343 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 344 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 345 | MACOSX_DEPLOYMENT_TARGET = 13.4; 346 | MARKETING_VERSION = 1.0; 347 | PRODUCT_BUNDLE_IDENTIFIER = com.emerge.DistributionDemoApp; 348 | PRODUCT_NAME = "$(TARGET_NAME)"; 349 | RUN_DOCUMENTATION_COMPILER = YES; 350 | SDKROOT = auto; 351 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 352 | SUPPORTS_MACCATALYST = NO; 353 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 354 | SWIFT_EMIT_LOC_STRINGS = YES; 355 | SWIFT_OBJC_BRIDGING_HEADER = "DemoApp/DemoApp-Bridging-Header.h"; 356 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 357 | SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; 358 | SWIFT_VERSION = 5.0; 359 | TARGETED_DEVICE_FAMILY = 1; 360 | }; 361 | name = Debug; 362 | }; 363 | FA1671D02A5367A800A42DB0 /* Release */ = { 364 | isa = XCBuildConfiguration; 365 | buildSettings = { 366 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 367 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 368 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 369 | CLANG_ENABLE_MODULES = YES; 370 | CODE_SIGN_ENTITLEMENTS = DemoApp/DemoApp.entitlements; 371 | CODE_SIGN_STYLE = Automatic; 372 | CURRENT_PROJECT_VERSION = 1; 373 | DEVELOPMENT_ASSET_PATHS = "\"DemoApp/Preview Content\""; 374 | DEVELOPMENT_TEAM = 62J2XHNK9T; 375 | ENABLE_HARDENED_RUNTIME = NO; 376 | ENABLE_PREVIEWS = YES; 377 | GCC_TREAT_WARNINGS_AS_ERRORS = YES; 378 | GENERATE_INFOPLIST_FILE = YES; 379 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 380 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 381 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 382 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 383 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 384 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 385 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 386 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 387 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 388 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 389 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 390 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 391 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 392 | MACOSX_DEPLOYMENT_TARGET = 13.4; 393 | MARKETING_VERSION = 1.0; 394 | PRODUCT_BUNDLE_IDENTIFIER = com.emerge.DistributionDemoApp; 395 | PRODUCT_NAME = "$(TARGET_NAME)"; 396 | RUN_DOCUMENTATION_COMPILER = YES; 397 | SDKROOT = auto; 398 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 399 | SUPPORTS_MACCATALYST = NO; 400 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 401 | SWIFT_EMIT_LOC_STRINGS = YES; 402 | SWIFT_OBJC_BRIDGING_HEADER = "DemoApp/DemoApp-Bridging-Header.h"; 403 | SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; 404 | SWIFT_VERSION = 5.0; 405 | TARGETED_DEVICE_FAMILY = 1; 406 | }; 407 | name = Release; 408 | }; 409 | /* End XCBuildConfiguration section */ 410 | 411 | /* Begin XCConfigurationList section */ 412 | FA1671BA2A5367A800A42DB0 /* Build configuration list for PBXProject "DemoApp" */ = { 413 | isa = XCConfigurationList; 414 | buildConfigurations = ( 415 | FA1671CC2A5367A800A42DB0 /* Debug */, 416 | FA1671CD2A5367A800A42DB0 /* Release */, 417 | ); 418 | defaultConfigurationIsVisible = 0; 419 | defaultConfigurationName = Release; 420 | }; 421 | FA1671CE2A5367A800A42DB0 /* Build configuration list for PBXNativeTarget "DemoApp" */ = { 422 | isa = XCConfigurationList; 423 | buildConfigurations = ( 424 | FA1671CF2A5367A800A42DB0 /* Debug */, 425 | FA1671D02A5367A800A42DB0 /* Release */, 426 | ); 427 | defaultConfigurationIsVisible = 0; 428 | defaultConfigurationName = Release; 429 | }; 430 | /* End XCConfigurationList section */ 431 | 432 | /* Begin XCLocalSwiftPackageReference section */ 433 | F41853CE2CB70E47007F7CE8 /* XCLocalSwiftPackageReference "../../ETDistribution" */ = { 434 | isa = XCLocalSwiftPackageReference; 435 | relativePath = ../../ETDistribution; 436 | }; 437 | /* End XCLocalSwiftPackageReference section */ 438 | 439 | /* Begin XCSwiftPackageProductDependency section */ 440 | F41853CF2CB70E47007F7CE8 /* ETDistribution */ = { 441 | isa = XCSwiftPackageProductDependency; 442 | productName = ETDistribution; 443 | }; 444 | /* End XCSwiftPackageProductDependency section */ 445 | }; 446 | rootObject = FA1671B72A5367A800A42DB0 /* Project object */; 447 | } 448 | -------------------------------------------------------------------------------- /Example/DemoApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/DemoApp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Example/DemoApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Example/DemoApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/DemoApp/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // DemoApp 4 | // 5 | // Created by Itay Brenner on 9/10/24. 6 | // 7 | 8 | import Foundation 9 | 10 | @objc 11 | class Constants: NSObject { 12 | @objc 13 | static let apiKey = "YOUR_API_KEY" 14 | 15 | @objc 16 | static let tagName = "manual" 17 | } 18 | -------------------------------------------------------------------------------- /Example/DemoApp/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // DemoApp 4 | // 5 | // Created by Itay Brenner on 9/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ContentView: View { 11 | var body: some View { 12 | VStack(spacing: 20.0) { 13 | Button("Check For Update Swift") { 14 | UpdateUtil.checkForUpdates() 15 | } 16 | .padding() 17 | .background(.blue) 18 | .foregroundColor(.white) 19 | .cornerRadius(10) 20 | 21 | Button("Check For Update With Login Swift") { 22 | UpdateUtil.checkForUpdatesWithLogin() 23 | } 24 | .padding() 25 | .background(.orange) 26 | .foregroundColor(.white) 27 | .cornerRadius(10) 28 | 29 | Button("Check For Update ObjC") { 30 | UpdateUtilObjc().checkForUpdates() 31 | } 32 | .padding() 33 | .background(.gray) 34 | .foregroundColor(.white) 35 | .cornerRadius(10) 36 | 37 | Button("Check For Update With Login ObjC") { 38 | UpdateUtilObjc().checkForUpdatesWithLogin() 39 | } 40 | .padding() 41 | .background(.yellow) 42 | .foregroundColor(.black) 43 | .cornerRadius(10) 44 | 45 | Button("Clear Tokens") { 46 | UpdateUtil.clearTokens() 47 | } 48 | .padding() 49 | .background(.mint) 50 | .foregroundColor(.black) 51 | .cornerRadius(10) 52 | } 53 | .padding() 54 | } 55 | } 56 | 57 | #Preview { 58 | ContentView() 59 | } 60 | -------------------------------------------------------------------------------- /Example/DemoApp/DemoApp-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Use this file to import your target's public headers that you would like to expose to Swift. 3 | // 4 | 5 | #import "UpdateUtilObjc.h" 6 | -------------------------------------------------------------------------------- /Example/DemoApp/DemoApp.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Example/DemoApp/DemoAppApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DemoAppApp.swift 3 | // DemoApp 4 | // 5 | // Created by Itay Brenner on 9/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct DemoAppApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Example/DemoApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/DemoApp/UpdateUtil.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdateUtil.swift 3 | // DemoApp 4 | // 5 | // Created by Itay Brenner on 9/10/24. 6 | // Copyright © 2024 Emerge Tools. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | import ETDistribution 12 | 13 | struct UpdateUtil { 14 | @MainActor 15 | static func checkForUpdates() { 16 | let params = CheckForUpdateParams(apiKey: Constants.apiKey, requiresLogin: false) 17 | ETDistribution.shared.checkForUpdate(params: params) { result in 18 | handleUpdateResult(result: result) 19 | } 20 | } 21 | 22 | @MainActor 23 | static func checkForUpdatesWithLogin() { 24 | let params = CheckForUpdateParams(apiKey: Constants.apiKey, requiresLogin: true) 25 | ETDistribution.shared.checkForUpdate(params: params) { result in 26 | handleUpdateResult(result: result) 27 | } 28 | } 29 | 30 | @MainActor 31 | static func handleUpdateResult(result: Result) { 32 | guard case let .success(releaseInfo) = result else { 33 | if case let .failure(error) = result { 34 | print("Error checking for update: \(error)") 35 | } 36 | return 37 | } 38 | 39 | guard let releaseInfo = releaseInfo else { 40 | print("Already up to date") 41 | return 42 | } 43 | 44 | print("Update found: \(releaseInfo), requires login: \(releaseInfo.loginRequiredForDownload)") 45 | if releaseInfo.loginRequiredForDownload { 46 | // Get new release info, with login 47 | ETDistribution.shared.getReleaseInfo(releaseId: releaseInfo.id) { newReleaseInfo in 48 | if case let .success(newReleaseInfo) = newReleaseInfo { 49 | UpdateUtil.installRelease(releaseInfo: newReleaseInfo) 50 | } 51 | } 52 | } else { 53 | UpdateUtil.installRelease(releaseInfo: releaseInfo) 54 | } 55 | } 56 | 57 | static func clearTokens() { 58 | delete(key: "accessToken") { 59 | delete(key: "refreshToken") { 60 | print("Tokens cleared") 61 | } 62 | } 63 | } 64 | 65 | private static func delete(key: String, completion: @escaping @Sendable () -> Void) { 66 | DispatchQueue.global().async { 67 | let attributes = [ 68 | kSecClass: kSecClassGenericPassword, 69 | kSecAttrService: "com.emerge.ETDistribution", 70 | kSecAttrAccount: key, 71 | ] as CFDictionary 72 | 73 | SecItemDelete(attributes) 74 | 75 | completion() 76 | } 77 | } 78 | 79 | @MainActor 80 | private static func installRelease(releaseInfo: DistributionReleaseInfo) { 81 | guard let url = ETDistribution.shared.buildUrlForInstall(releaseInfo.downloadUrl) else { 82 | return 83 | } 84 | DispatchQueue.main.async { 85 | UIApplication.shared.open(url) { _ in 86 | exit(0) 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Example/DemoApp/UpdateUtilObjc.h: -------------------------------------------------------------------------------- 1 | // 2 | // UpdateUtilObjc.h 3 | // DemoApp 4 | // 5 | // Created by Itay Brenner on 9/10/24. 6 | // Copyright © 2024 Emerge Tools. All rights reserved. 7 | // 8 | 9 | 10 | #import 11 | 12 | NS_ASSUME_NONNULL_BEGIN 13 | 14 | @interface UpdateUtilObjc : NSObject 15 | - (void) checkForUpdates; 16 | - (void) checkForUpdatesWithLogin; 17 | @end 18 | 19 | NS_ASSUME_NONNULL_END 20 | -------------------------------------------------------------------------------- /Example/DemoApp/UpdateUtilObjc.m: -------------------------------------------------------------------------------- 1 | // 2 | // UpdateUtilObjc.m 3 | // DemoApp 4 | // 5 | // Created by Itay Brenner on 9/10/24. 6 | // Copyright © 2024 Emerge Tools. All rights reserved. 7 | // 8 | 9 | 10 | #import "UpdateUtilObjc.h" 11 | #import "DemoApp-Swift.h" 12 | @import ETDistribution; 13 | 14 | @implementation UpdateUtilObjc 15 | - (void) checkForUpdates { 16 | CheckForUpdateParams *params = [[CheckForUpdateParams alloc] initWithApiKey:[Constants apiKey] 17 | tagName:[Constants tagName] 18 | requiresLogin:NO 19 | binaryIdentifierOverride:NULL 20 | appIdOverride:NULL]; 21 | [[ETDistribution sharedInstance] checkForUpdateWithParams:params 22 | onReleaseAvailable:^(DistributionReleaseInfo *releaseInfo) { 23 | NSLog(@"Release info: %@", releaseInfo); 24 | } 25 | onError:^(NSError *error) { 26 | NSLog(@"Error checking for update: %@", error); 27 | }]; 28 | } 29 | 30 | - (void) checkForUpdatesWithLogin { 31 | CheckForUpdateParams *params = [[CheckForUpdateParams alloc] initWithApiKey:[Constants apiKey] 32 | tagName:[Constants tagName] 33 | requiresLogin:YES 34 | binaryIdentifierOverride:NULL 35 | appIdOverride:NULL]; 36 | [[ETDistribution sharedInstance] checkForUpdateWithParams:params 37 | onReleaseAvailable:^(DistributionReleaseInfo *releaseInfo) { 38 | NSLog(@"Release info: %@", releaseInfo); 39 | } 40 | onError:^(NSError *error) { 41 | NSLog(@"Error checking for update: %@", error); 42 | }]; 43 | } 44 | @end 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Emerge Tools 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "ETDistribution", 8 | platforms: [.iOS(.v13)], 9 | products: [ 10 | .library( 11 | name: "ETDistribution", 12 | type: .dynamic, 13 | targets: ["ETDistribution"] 14 | ), 15 | ], 16 | dependencies: [], 17 | targets: [ 18 | .target( 19 | name: "ETDistribution", 20 | dependencies: [], 21 | path: "Sources" 22 | ), 23 | .testTarget( 24 | name: "ETDistributionTests", 25 | dependencies: ["ETDistribution"], 26 | path: "Tests" 27 | ), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ETDistribution 🛰️ 2 | 3 | [![](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.emergetools.com%2Fapi%2Fv2%2Fpublic_new_build%3FexampleId%3Detdistribution.ETDistribution%26platform%3Dios%26badgeOption%3Dversion_and_max_install_size%26buildType%3Dmanual&query=$.badgeMetadata&label=ETDistribution&logo=apple)](https://www.emergetools.com/app/example/ios/etdistribution.ETDistribution/manual) 4 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FEmergeTools%2FETDistribution%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/EmergeTools/ETDistribution) 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FEmergeTools%2FETDistribution%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/EmergeTools/ETDistribution) 6 | 7 | ETDistribution is a Swift library that simplifies the process of distributing new app versions and checking for updates. It provides an easy-to-use API to verify if a new version is available and handles the update process seamlessly, ensuring your users are always on the latest release. 8 | 9 | ## Features 10 | 11 | - 🚀 Automatic Update Check: Quickly determine if a new version is available. 12 | - 🔑 Secure: No data is stored locally. 13 | - 🎯 Objective-C Compatibility: Provides compatibility with both Swift and Objective-C. 14 | - 🛠️ Flexible Update Handling: Customize how you handle updates. 15 | 16 | ## Installation 17 | 18 | ### Prerequisites: 19 | - iOS 13.0+ 20 | - Xcode 12.0+ 21 | - Swift 5.0+ 22 | 23 | ### Swift Package Manager 24 | 25 | To integrate ETDistribution into your project, add the following line to your Package.swift: 26 | ```swift 27 | dependencies: [ 28 | .package(url: "https://github.com/EmergeTools/ETDistribution.git", from: "v0.1.2") 29 | ] 30 | ``` 31 | 32 | ### Manual Integration 33 | 1. Clone the repository. 34 | 2. Drag the ETDistribution folder into your Xcode project. 35 | 36 | ## Usage 37 | 38 | ### Checking for Updates 39 | The ETDistribution library provides a simple API to check for updates: 40 | ```swift 41 | import UIKit 42 | import ETDistribution 43 | 44 | ETDistribution.shared.checkForUpdate(apiKey: "YOUR_API_KEY") { result in 45 | switch result { 46 | case .success(let releaseInfo): 47 | if let releaseInfo { 48 | print("Update found: \(releaseInfo)") 49 | guard let url = ETDistribution.shared.buildUrlForInstall(releaseInfo.downloadUrl) else { 50 | return 51 | } 52 | DispatchQueue.main.async { 53 | UIApplication.shared.open(url) { _ in 54 | exit(0) 55 | } 56 | } 57 | } else { 58 | print("Already up to date") 59 | } 60 | case .failure(let error): 61 | print("Error checking for update: \(error)") 62 | } 63 | } 64 | ``` 65 | 66 | For Objective-C: 67 | ```objc 68 | [[ETDistribution sharedInstance] checkForUpdateWithApiKey:@"YOUR_API_KEY" 69 | tagName:nil 70 | onReleaseAvailable:^(DistributionReleaseInfo *releaseInfo) { 71 | NSLog(@"Release info: %@", releaseInfo); 72 | } 73 | onError:^(NSError *error) { 74 | NSLog(@"Error checking for update: %@", error); 75 | }]; 76 | ``` 77 | 78 | If you do not provide a completion handler, a default UI will be shown asking if the update should be installed. 79 | 80 | ## Configuration 81 | 82 | ### API Key 83 | 84 | An API key is required to authenticate requests. You can obtain your API key from the [build distribution configuration](https://www.emergetools.com/settings?tab=feature-configuration&cards=distribution_enabled). Once you have the key, you can pass it as a parameter to the `checkForUpdate` method. 85 | 86 | ### Tag Name (Optional) 87 | 88 | Tags can be used to associate builds, you could use tags to represent the dev branch, an internal project or any team builds. If the same binary has been uploaded with multiple tags, you can specify a tagName to differentiate between them. This is usually not needed, as the SDK will identify the tag automatically. 89 | 90 | ### Login Level 91 | 92 | Login levels can be configured to require login for certain actions (like downloading the update or checking for updates). They are set at [Emerge Tools Settings](https://www.emergetools.com/settings?tab=feature-configuration). You should match that level at the app level. 93 | 94 | ### Debug Overrides 95 | 96 | There are several override options to help debug integration and test the SDK. 97 | They are: 98 | - **binaryIdentifierOverride**: Allows overriding the binary identifier to test updates from a different build. 99 | - **appIdOverride**: Allows changing the application identifier (aka Bundle Id). 100 | 101 | ### Handling Responses 102 | 103 | By default, if no completion closure is provided, the SDK will present an alert to the user, prompting them to install the release. You can customize this behavior using the closures provided by the API. 104 | 105 | ## Example Project 106 | To see ETDistribution in action, check out our example project. The example demonstrates how to integrate and use the library in both Swift and Objective-C projects. 107 | 108 | ## Documentation 109 | 110 | For more detailed documentation and additional examples, visit our [Documentation Site](https://docs.emergetools.com/). 111 | 112 | ## FAQ 113 | 114 | ### Why isn’t the update check working on the simulator? 115 | 116 | The library is designed to skip update checks on the simulator. To test update functionality, run your app on a physical device. 117 | 118 | ### Why I am not getting any update? 119 | 120 | There could be several reasons: 121 | - Update checks are disabled for both Simulators and Debug builds. 122 | - ETDistribution is intended to update from an already published version on ETDistribution. If the current build has not been uploaded to Emerge Tools, you won't get any update notification. 123 | 124 | ### How do I skip an update? 125 | 126 | When handling the response you can check the release version field to decide if it should be installed or not. 127 | 128 | ### Can I use ETDistribution to get updates from the AppStore? 129 | 130 | No, since the binary signer is different (builds installed from the AppStore are signed by Apple), the update will fail. 131 | 132 | ### Can I require login to get updates? 133 | 134 | Yes, there are 3 options for security: 135 | - No login required. 136 | - Login required only for downloading the update (can check for updates without login). 137 | - Login required for checking for updates. 138 | 139 | These options can be configured per platform at [Emerge Tools Settings](https://www.emergetools.com/settings?tab=feature-configuration). 140 | -------------------------------------------------------------------------------- /Sources/Auth.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Auth.swift 3 | // ETDistribution 4 | // 5 | // Created by Noah Martin on 9/23/24. 6 | // 7 | 8 | import AuthenticationServices 9 | import CommonCrypto 10 | 11 | enum LoginError: Error { 12 | case noUrl 13 | case noCode 14 | case invalidData 15 | } 16 | 17 | enum Auth { 18 | private enum Constants { 19 | static let url = URL(string: "https://auth.emergetools.com")! 20 | static let clientId = "XiFbzCzBHV5euyxbcxNHbqOHlKcTwzBX" 21 | static let redirectUri = URL(string: "app.install.callback://callback")! 22 | static let accessTokenKey = "accessToken" 23 | static let refreshTokenKey = "refreshToken" 24 | } 25 | 26 | static func getAccessToken(settings: LoginSetting, completion: @escaping @MainActor (Result) -> Void) { 27 | KeychainHelper.getToken(key: Constants.accessTokenKey) { token in 28 | if let token = token, 29 | JWTHelper.isValid(token: token) { 30 | DispatchQueue.main.async { 31 | completion(.success(token)) 32 | } 33 | } else { 34 | KeychainHelper.getToken(key: Constants.accessTokenKey) { refreshToken in 35 | if let refreshToken = refreshToken, 36 | JWTHelper.isValid(token: refreshToken) { 37 | refreshAccessToken(refreshToken) { result in 38 | DispatchQueue.main.async { 39 | switch result { 40 | case .success(let accessToken): 41 | completion(.success(accessToken)) 42 | case .failure: 43 | requestLogin(settings, completion) 44 | } 45 | } 46 | } 47 | } else { 48 | DispatchQueue.main.async { 49 | requestLogin(settings, completion) 50 | } 51 | } 52 | } 53 | } 54 | } 55 | } 56 | 57 | @MainActor 58 | private static func requestLogin(_ settings: LoginSetting, _ completion: @escaping @MainActor (Result) -> Void) { 59 | login(settings: settings) { result in 60 | switch result { 61 | case .success(let response): 62 | KeychainHelper.setToken(response.accessToken, key: Constants.accessTokenKey) { error in 63 | if let error = error { 64 | completion(.failure(error)) 65 | return 66 | } 67 | 68 | KeychainHelper.setToken(response.refreshToken, key: Constants.refreshTokenKey) { error in 69 | if let error = error { 70 | completion(.failure(error)) 71 | return 72 | } 73 | 74 | completion(.success(response.accessToken)) 75 | } 76 | } 77 | case .failure(let error): 78 | completion(.failure(error)) 79 | } 80 | } 81 | } 82 | 83 | private static func refreshAccessToken(_ refreshToken: String, completion: @escaping @MainActor (Result) -> Void) { 84 | let url = URL(string: "oauth/token", relativeTo: Constants.url)! 85 | 86 | let parameters = [ 87 | "grant_type": "refresh_token", 88 | "client_id": Constants.clientId, 89 | "refresh_token": refreshToken, 90 | ] 91 | 92 | var request = URLRequest(url: url) 93 | request.httpMethod = "POST" 94 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 95 | request.httpBody = try! JSONSerialization.data(withJSONObject: parameters, options: []) 96 | 97 | URLSession(configuration: URLSessionConfiguration.ephemeral).refreshAccessToken(request) { requestResult in 98 | var result: Result = .failure(RequestError.unknownError) 99 | defer { 100 | DispatchQueue.main.async { [result] in 101 | completion(result) 102 | } 103 | } 104 | 105 | switch requestResult { 106 | case .success(let response): 107 | KeychainHelper.setToken(response.accessToken, key: Constants.accessTokenKey) { error in 108 | if let error = error { 109 | result = .failure(error) 110 | return 111 | } 112 | result = .success(response.accessToken) 113 | } 114 | case .failure(let error): 115 | result = .failure(error) 116 | } 117 | } 118 | } 119 | 120 | @MainActor private static func login( 121 | settings: LoginSetting, 122 | completion: @escaping @MainActor (Result) -> Void) 123 | { 124 | let verifier = getVerifier()! 125 | let challenge = getChallenge(for: verifier)! 126 | 127 | let authorize = URL(string: "authorize", relativeTo: Constants.url)! 128 | var components = URLComponents(url: authorize, resolvingAgainstBaseURL: true)! 129 | var items: [URLQueryItem] = [] 130 | var entries: [String: String] = [:] 131 | 132 | entries["scope"] = "openid profile email offline_access" 133 | entries["client_id"] = Constants.clientId 134 | entries["response_type"] = "code" 135 | if case .connection(let string) = settings { 136 | entries["connection"] = string 137 | } 138 | entries["redirect_uri"] = Constants.redirectUri.absoluteString 139 | entries["state"] = generateDefaultState() 140 | entries["audience"] = "https://auth0-jwt-authorizer" 141 | entries["code_challenge"] = challenge 142 | entries["code_challenge_method"] = "S256" 143 | entries.forEach { items.append(URLQueryItem(name: $0, value: $1)) } 144 | components.queryItems = items 145 | components.percentEncodedQuery = components.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B") 146 | 147 | let url = components.url! 148 | let session = ASWebAuthenticationSession( 149 | url: url, 150 | callbackURLScheme: Constants.redirectUri.scheme!) { url, error in 151 | if let error { 152 | DispatchQueue.main.async { 153 | completion(.failure(error)) 154 | } 155 | return 156 | } 157 | 158 | if let url { 159 | let components = URLComponents(url: url, resolvingAgainstBaseURL: false) 160 | let code = components!.queryItems!.first(where: { $0.name == "code"}) 161 | if let code { 162 | self.exchangeAuthorizationCodeForTokens(authorizationCode: code.value!, verifier: verifier) { result in 163 | completion(result) 164 | } 165 | } else { 166 | DispatchQueue.main.async { 167 | completion(.failure(LoginError.noCode)) 168 | } 169 | } 170 | } else { 171 | DispatchQueue.main.async { 172 | completion(.failure(LoginError.noUrl)) 173 | } 174 | } 175 | } 176 | session.presentationContextProvider = PresentationContextProvider.shared 177 | session.start() 178 | } 179 | 180 | private static func exchangeAuthorizationCodeForTokens( 181 | authorizationCode: String, 182 | verifier: String, 183 | completion: @escaping @MainActor (Result) -> Void) 184 | { 185 | let url = URL(string: "oauth/token", relativeTo: Constants.url)! 186 | 187 | let parameters = [ 188 | "grant_type": "authorization_code", 189 | "code_verifier": verifier, 190 | "client_id": Constants.clientId, 191 | "code": authorizationCode, 192 | "redirect_uri": Constants.redirectUri.absoluteString, 193 | ] 194 | 195 | var request = URLRequest(url: url) 196 | request.httpMethod = "POST" 197 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 198 | request.httpBody = try! JSONSerialization.data(withJSONObject: parameters, options: []) 199 | 200 | URLSession(configuration: URLSessionConfiguration.ephemeral).getAuthDataWith(request, completion: completion) 201 | } 202 | 203 | private static func getVerifier() -> String? { 204 | let data = Data(count: 32) 205 | var tempData = data 206 | _ = tempData.withUnsafeMutableBytes { 207 | SecRandomCopyBytes(kSecRandomDefault, data.count, $0.baseAddress!) 208 | } 209 | return tempData.a0_encodeBase64URLSafe() 210 | } 211 | 212 | private static func getChallenge(for verifier: String) -> String? { 213 | guard let data = verifier.data(using: .utf8) else { return nil } 214 | 215 | var buffer = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) 216 | _ = data.withUnsafeBytes { 217 | CC_SHA256($0.baseAddress, CC_LONG(data.count), &buffer) 218 | } 219 | return Data(buffer).a0_encodeBase64URLSafe() 220 | } 221 | 222 | private static func generateDefaultState() -> String { 223 | let data = Data(count: 32) 224 | var tempData = data 225 | 226 | let result = tempData.withUnsafeMutableBytes { 227 | SecRandomCopyBytes(kSecRandomDefault, data.count, $0.baseAddress!) 228 | } 229 | 230 | guard result == 0, let state = tempData.a0_encodeBase64URLSafe() 231 | else { return UUID().uuidString.replacingOccurrences(of: "-", with: "") } 232 | 233 | return state 234 | } 235 | } 236 | 237 | private class PresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { 238 | fileprivate static let shared = PresentationContextProvider() 239 | 240 | func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { 241 | if 242 | let windowScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene, 243 | let window = windowScene.windows.first(where: \.isKeyWindow) { 244 | return window 245 | } 246 | return ASPresentationAnchor() 247 | } 248 | } 249 | 250 | extension Data { 251 | fileprivate func a0_encodeBase64URLSafe() -> String? { 252 | return self 253 | .base64EncodedString(options: []) 254 | .replacingOccurrences(of: "+", with: "-") 255 | .replacingOccurrences(of: "/", with: "_") 256 | .replacingOccurrences(of: "=", with: "") 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /Sources/ETDistribution.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ETDistribution.swift 3 | // 4 | // 5 | // Created by Itay Brenner on 5/9/24. 6 | // 7 | 8 | import UIKit 9 | import Foundation 10 | 11 | @objc @MainActor 12 | public final class ETDistribution: NSObject { 13 | // MARK: - Public 14 | @objc(sharedInstance) 15 | public static let shared = ETDistribution() 16 | 17 | /// Checks if there is an update available for the app, based on the provided `params`. 18 | /// 19 | /// 20 | /// - Parameters: 21 | /// - params: A `CheckForUpdateParams` object. 22 | /// - completion: An optional closure that is called with the result of the update check. If `DistributionReleaseInfo` is nil, there is no updated available. If the closure is not provided, the SDK will present an alert to the user prompting to install the release. 23 | /// 24 | /// - Example: 25 | /// ``` 26 | /// let params = CheckForUpdateParams(apiKey: "your_api_key") 27 | /// checkForUpdate(params: params) { result in 28 | /// switch result { 29 | /// case .success(let releaseInfo): 30 | /// if let releaseInfo { 31 | /// print("Update found: \(releaseInfo)") 32 | /// } else { 33 | /// print("Already up to date") 34 | /// } 35 | /// case .failure(let error): 36 | /// print("Error checking for update: \(error)") 37 | /// } 38 | /// } 39 | /// ``` 40 | public func checkForUpdate(params: CheckForUpdateParams, 41 | completion: (@MainActor (Result) -> Void)? = nil) { 42 | checkRequest(params: params, completion: completion) 43 | } 44 | 45 | /// Checks if there is an update available for the app, based on the provided `params` with Objective-C compatibility. 46 | /// 47 | /// This function is designed for compatibility with Objective-C. 48 | /// 49 | /// - Parameters: 50 | /// - params: A `CheckForUpdateParams` object. 51 | /// - onReleaseAvailable: An optional closure that is called with the result of the update check. If `DistributionReleaseInfo` is nil, 52 | /// there is no updated available. If the closure is not provided, the SDK will present an alert to the user prompting to install the release. 53 | /// - onError: An optional closure that is called with an `Error` object if the update check fails. If no error occurs, this closure is not called. 54 | /// 55 | /// 56 | /// - Example: 57 | /// ``` 58 | /// let params = CheckForUpdateParams(apiKey: "your_api_key") 59 | /// checkForUpdate(params: params, onReleaseAvailable: { releaseInfo in 60 | /// print("Release info: \(releaseInfo)") 61 | /// }, onError: { error in 62 | /// print("Error checking for update: \(error)") 63 | /// }) 64 | /// ``` 65 | @objc 66 | public func checkForUpdate(params: CheckForUpdateParams, 67 | onReleaseAvailable: (@MainActor (DistributionReleaseInfo?) -> Void)? = nil, 68 | onError: (@MainActor (Error) -> Void)? = nil) { 69 | checkRequest(params: params) { result in 70 | switch result { 71 | case.success(let releaseInfo): 72 | onReleaseAvailable?(releaseInfo) 73 | case.failure(let error): 74 | onError?(error) 75 | } 76 | } 77 | } 78 | 79 | /// Obtain a URL to install an IPA 80 | /// - Parameter plistUrl: The URL to the plist containing the IPA information 81 | /// - Returns: a URL ready to install the IPA using Itunes Services 82 | public func buildUrlForInstall(_ plistUrl: String) -> URL? { 83 | guard plistUrl != "REQUIRES_LOGIN", 84 | var components = URLComponents(string: "itms-services://") else { 85 | return nil 86 | } 87 | components.queryItems = [ 88 | URLQueryItem(name: "action", value: "download-manifest"), 89 | URLQueryItem(name: "url", value: plistUrl) 90 | ] 91 | return components.url 92 | } 93 | 94 | public func getReleaseInfo(releaseId: String, completion: @escaping (@MainActor (Result) -> Void)) { 95 | if let loginSettings = loginSettings, 96 | (loginLevel?.rawValue ?? 0) > LoginLevel.noLogin.rawValue { 97 | Auth.getAccessToken(settings: loginSettings) { [weak self] result in 98 | switch result { 99 | case .success(let accessToken): 100 | self?.getReleaseInfo(releaseId: releaseId, accessToken: accessToken, completion: completion) 101 | case .failure(let error): 102 | completion(.failure(error)) 103 | } 104 | } 105 | } else { 106 | getReleaseInfo(releaseId: releaseId, accessToken: nil) { [weak self] result in 107 | if case .failure(let error) = result, 108 | case RequestError.loginRequired = error { 109 | // Attempt login if backend returns "Login Required" 110 | self?.loginSettings = LoginSetting.default 111 | self?.loginLevel = .onlyForDownload 112 | self?.getReleaseInfo(releaseId: releaseId, completion: completion) 113 | return 114 | } 115 | completion(result) 116 | } 117 | } 118 | } 119 | 120 | /// Show prompt to install an update 121 | /// - Parameter release: A Distribution Release Object 122 | public func showReleaseInstallPrompt(for release: DistributionReleaseInfo) { 123 | guard release.id != UserDefaults.skippedRelease, 124 | (UserDefaults.postponeTimeout == nil || UserDefaults.postponeTimeout! < Date() ) else { 125 | return 126 | } 127 | print("ETDistribution: Update Available: \(release.downloadUrl)") 128 | let message = "New version \(release.version) is available" 129 | 130 | var actions = [AlertAction]() 131 | actions.append(AlertAction(title: "Install", 132 | style: .default, 133 | handler: { [weak self] _ in 134 | self?.handleInstallRelease(release) 135 | })) 136 | actions.append(AlertAction(title: "Postpone updates for 1 day", 137 | style: .cancel, 138 | handler: { [weak self] _ in 139 | self?.handlePostponeRelease() 140 | })) 141 | actions.append(AlertAction(title: "Skip", 142 | style: .destructive, 143 | handler: { [weak self] _ in 144 | self?.handleSkipRelease(release) 145 | })) 146 | 147 | DispatchQueue.main.async { 148 | UIViewController.showAlert(title: "Update Available", 149 | message: message, 150 | actions: actions) 151 | } 152 | } 153 | 154 | // MARK: - Private 155 | private lazy var session = URLSession(configuration: URLSessionConfiguration.ephemeral) 156 | private lazy var uuid = BinaryParser.getMainBinaryUUID() 157 | private var loginSettings: LoginSetting? 158 | private var loginLevel: LoginLevel? 159 | private var apiKey: String = "" 160 | 161 | override private init() { 162 | super.init() 163 | } 164 | 165 | private func checkRequest(params: CheckForUpdateParams, 166 | completion: (@MainActor (Result) -> Void)? = nil) { 167 | apiKey = params.apiKey 168 | loginLevel = params.loginLevel 169 | loginSettings = params.loginSetting 170 | 171 | if let loginSettings = params.loginSetting, 172 | params.loginLevel == .everything { 173 | Auth.getAccessToken(settings: loginSettings) { [weak self] result in 174 | switch result { 175 | case .success(let accessToken): 176 | self?.getUpdatesFromBackend(params: params, accessToken: accessToken, completion: completion) 177 | case .failure(let error): 178 | completion?(.failure(error)) 179 | } 180 | } 181 | } else { 182 | getUpdatesFromBackend(params: params, accessToken: nil) { [weak self] result in 183 | if case .failure(let error) = result, 184 | case RequestError.loginRequired = error { 185 | // Attempt login if backend returns "Login Required" 186 | let params = CheckForUpdateParams(apiKey: params.apiKey, tagName: params.tagName, requiresLogin: true) 187 | self?.checkRequest(params: params, completion: completion) 188 | return 189 | } 190 | completion?(result) 191 | } 192 | } 193 | } 194 | 195 | private func getUpdatesFromBackend(params: CheckForUpdateParams, 196 | accessToken: String? = nil, 197 | completion: (@MainActor (Result) -> Void)? = nil) { 198 | guard var components = URLComponents(string: "https://api.emergetools.com/distribution/checkForUpdates") else { 199 | fatalError("Invalid URL") 200 | } 201 | 202 | components.queryItems = [ 203 | URLQueryItem(name: "apiKey", value: params.apiKey), 204 | URLQueryItem(name: "binaryIdentifier", value: params.binaryIdentifierOverride ?? uuid), 205 | URLQueryItem(name: "appId", value: params.appIdOverride ?? Bundle.main.bundleIdentifier), 206 | URLQueryItem(name: "platform", value: "ios") 207 | ] 208 | if let tagName = params.tagName { 209 | components.queryItems?.append(URLQueryItem(name: "tag", value: tagName)) 210 | } 211 | 212 | guard let url = components.url else { 213 | fatalError("Invalid URL") 214 | } 215 | var request = URLRequest(url: url) 216 | request.httpMethod = "GET" 217 | if let accessToken = accessToken { 218 | request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 219 | } 220 | 221 | session.checkForUpdate(request) { [weak self] result in 222 | let mappedResult = result.map { $0.updateInfo } 223 | if let completion = completion { 224 | completion(mappedResult) 225 | } else if let response = try? mappedResult.get() { 226 | self?.showReleaseInstallPrompt(for: response) 227 | } 228 | } 229 | } 230 | 231 | private func getReleaseInfo(releaseId: String, 232 | accessToken: String? = nil, 233 | completion: @escaping @MainActor (Result) -> Void) { 234 | guard var components = URLComponents(string: "https://api.emergetools.com/distribution/getRelease") else { 235 | fatalError("Invalid URL") 236 | } 237 | 238 | components.queryItems = [ 239 | URLQueryItem(name: "apiKey", value: apiKey), 240 | URLQueryItem(name: "uploadId", value: releaseId), 241 | URLQueryItem(name: "platform", value: "ios") 242 | ] 243 | 244 | guard let url = components.url else { 245 | fatalError("Invalid URL") 246 | } 247 | var request = URLRequest(url: url) 248 | request.httpMethod = "GET" 249 | if let accessToken = accessToken { 250 | request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 251 | } 252 | 253 | session.getReleaseInfo(request, completion: completion) 254 | } 255 | 256 | private func handleInstallRelease(_ release: DistributionReleaseInfo) { 257 | if release.loginRequiredForDownload, let loginSettings = loginSettings { 258 | Auth.getAccessToken(settings: loginSettings) { [weak self] result in 259 | guard case let .success(accessToken) = result else { 260 | return 261 | } 262 | self?.getReleaseInfo(releaseId: release.id, accessToken: accessToken) { [weak self] result in 263 | guard case .success(let release) = result else { 264 | return 265 | } 266 | self?.installAppWithDownloadString(release.downloadUrl) 267 | } 268 | } 269 | } else { 270 | installAppWithDownloadString(release.downloadUrl) 271 | } 272 | } 273 | 274 | private func installAppWithDownloadString(_ urlString: String) { 275 | guard let url = self.buildUrlForInstall(urlString) else { 276 | return 277 | } 278 | UIApplication.shared.open(url) { _ in 279 | // We need to exit since iOS doesn't start the install until the app exits 280 | exit(0) 281 | } 282 | } 283 | 284 | private func handleSkipRelease(_ release: DistributionReleaseInfo) { 285 | UserDefaults.skippedRelease = release.id 286 | } 287 | 288 | private func handlePostponeRelease() { 289 | UserDefaults.postponeTimeout = Date(timeIntervalSinceNow: 60 * 60 * 24) 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /Sources/JWT/JWTHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JWTHelper.swift 3 | // ETDistribution 4 | // 5 | // Created by Itay Brenner on 30/10/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum JWTHelper { 11 | /// Only checks for the expiration, not the signature 12 | static func isValid(token: String) -> Bool { 13 | guard let payload = parsePayload(token), 14 | let exp = payload["exp"] as? TimeInterval else { 15 | return false 16 | } 17 | let expirationDate = Date(timeIntervalSince1970: exp) 18 | return Date() < expirationDate 19 | } 20 | 21 | private static func parsePayload(_ token: String) -> [String: Any]? { 22 | let segments = token.split(separator: ".") 23 | guard segments.count > 1 else { return nil } 24 | 25 | let payloadSegment = String(segments[1]) 26 | 27 | guard let decodedData = decodeBase64URL(payloadSegment), 28 | let json = try? JSONSerialization.jsonObject(with: decodedData, options: []), 29 | let payload = json as? [String: Any] else { 30 | return nil 31 | } 32 | 33 | return payload 34 | } 35 | 36 | private static func decodeBase64URL(_ string: String) -> Data? { 37 | var base64 = string 38 | .replacingOccurrences(of: "-", with: "+") 39 | .replacingOccurrences(of: "_", with: "/") 40 | 41 | while base64.count % 4 != 0 { 42 | base64 += "=" 43 | } 44 | 45 | return Data(base64Encoded: base64) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/Keychain/KeychainHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // ETDistribution 4 | // 5 | // Created by Itay Brenner on 30/10/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum KeychainError: LocalizedError { 11 | case unexpectedStatus(OSStatus) 12 | } 13 | 14 | enum KeychainHelper { 15 | static let service = "com.emerge.ETDistribution" 16 | 17 | static func setToken(_ token: String, key: String, completion: @escaping @MainActor (Error?) -> Void) { 18 | getToken(key: key) { existingToken in 19 | // This is executed in a background thread 20 | var result: Error? = nil 21 | defer { 22 | DispatchQueue.main.async { [result] in 23 | completion(result) 24 | } 25 | } 26 | 27 | do { 28 | if existingToken == nil { 29 | try addToken(token, key: key) 30 | } else { 31 | try updateToken(token, key: key) 32 | } 33 | result = nil 34 | } catch { 35 | result = error 36 | } 37 | } 38 | } 39 | 40 | static func getToken(key: String, completion: @escaping @Sendable (String?) -> Void) { 41 | DispatchQueue.global(qos: .userInitiated).async { 42 | let query = [ 43 | kSecClass: kSecClassGenericPassword, 44 | kSecAttrService: service, 45 | kSecAttrAccount: key, 46 | kSecMatchLimit: kSecMatchLimitOne, 47 | kSecReturnData: true 48 | ] as CFDictionary 49 | 50 | var result: AnyObject? 51 | let status = SecItemCopyMatching(query, &result) 52 | 53 | let token: String? = { 54 | guard status == errSecSuccess, 55 | let data = result as? Data else { 56 | return nil 57 | } 58 | return dataToToken(data) 59 | }() 60 | 61 | completion(token) 62 | } 63 | } 64 | 65 | private static func addToken(_ token: String, key: String) throws { 66 | let data = tokenToData(token) 67 | 68 | let attributes = [ 69 | kSecClass: kSecClassGenericPassword, 70 | kSecAttrService: service, 71 | kSecAttrAccount: key, 72 | kSecValueData: data 73 | ] as CFDictionary 74 | 75 | let status = SecItemAdd(attributes, nil) 76 | guard status == errSecSuccess else { 77 | throw KeychainError.unexpectedStatus(status) 78 | } 79 | } 80 | 81 | private static func updateToken(_ token: String, key: String) throws { 82 | let data = tokenToData(token) 83 | let query = [ 84 | kSecClass: kSecClassGenericPassword, 85 | kSecAttrService: service, 86 | kSecAttrAccount: key 87 | ] as CFDictionary 88 | 89 | let attributes = [ 90 | kSecValueData: data 91 | ] as CFDictionary 92 | 93 | let status = SecItemUpdate(query, attributes) 94 | guard status == errSecSuccess else { 95 | throw KeychainError.unexpectedStatus(status) 96 | } 97 | } 98 | 99 | private static func tokenToData(_ token: String) -> Data { 100 | return token.data(using: .utf8) ?? Data() 101 | } 102 | 103 | private static func dataToToken(_ data: Data) -> String { 104 | return String(data: data, encoding: .utf8) ?? "" 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Sources/Mach-O/BinaryParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BinaryParser.swift 3 | // 4 | // 5 | // Created by Itay Brenner on 5/9/24. 6 | // 7 | 8 | import Foundation 9 | import Darwin 10 | import MachO 11 | 12 | fileprivate let _dyld_get_image_uuid = unsafeBitCast(dlsym(dlopen(nil, RTLD_LAZY), "_dyld_get_image_uuid"), to: (@convention(c) (UnsafePointer, UnsafeRawPointer) -> Bool)?.self)! 13 | 14 | struct BinaryParser { 15 | private static func formatUUID(_ uuid: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8)) -> String { 16 | "\(uuid.0.asHex)\(uuid.1.asHex)\(uuid.2.asHex)\(uuid.3.asHex)-\(uuid.4.asHex)\(uuid.5.asHex)-\(uuid.6.asHex)\(uuid.7.asHex)-\(uuid.8.asHex)\(uuid.9.asHex)-\(uuid.10.asHex)\(uuid.11.asHex)\(uuid.12.asHex)\(uuid.13.asHex)\(uuid.14.asHex)\(uuid.15.asHex)" 17 | } 18 | 19 | static func getMainBinaryUUID() -> String { 20 | guard let executableName = Bundle.main.infoDictionary?["CFBundleExecutable"] as? String else { 21 | fatalError("Executable name not found.") 22 | } 23 | let executablePath = "\(Bundle.main.bundlePath)/\(executableName)" 24 | 25 | for i in 0..<_dyld_image_count() { 26 | guard let header = _dyld_get_image_header(i) else { continue } 27 | let imagePath = String(cString: _dyld_get_image_name(i)) 28 | 29 | guard imagePath == executablePath else { 30 | continue 31 | } 32 | 33 | var _uuid = UUID().uuid 34 | let _ = withUnsafeMutablePointer(to: &_uuid) { 35 | _dyld_get_image_uuid(header, $0) 36 | } 37 | return formatUUID(_uuid) 38 | } 39 | return "" 40 | } 41 | } 42 | 43 | private extension UInt8 { 44 | var asHex: String { 45 | String(format: "%02X", self) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/Models/AuthCodeResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // ETDistribution 4 | // 5 | // Created by Itay Brenner on 31/10/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct AuthCodeResponse: Decodable { 11 | let tokenType: String 12 | let idToken: String 13 | let expiresIn: Int 14 | let accessToken: String 15 | let refreshToken: String 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Models/AuthRefreshResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthRefreshResponse.swift 3 | // ETDistribution 4 | // 5 | // Created by Itay Brenner on 31/10/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct AuthRefreshResponse: Decodable { 11 | let tokenType: String 12 | let idToken: String 13 | let accessToken: String 14 | let scope: String 15 | let expiresIn: Int 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Models/CheckForUpdateParams.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CheckForUpdateParams.swift 3 | // ETDistribution 4 | // 5 | // Created by Noah Martin on 10/31/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Type of authenticated access to required. The default case shows the Emerge Tools login page. 11 | /// A custom connection can be used to automatically redirect to an SSO page. 12 | public enum LoginSetting: Sendable { 13 | case `default` 14 | case connection(String) 15 | } 16 | 17 | /// Level of login required. By default no login is required 18 | /// Available levels: 19 | /// - none: No login is requiried 20 | /// - onlyForDownload: login is required only when downloading the app 21 | /// - everything: login is always required when doing API calls. 22 | @objc 23 | public enum LoginLevel: Int, Sendable { 24 | case noLogin 25 | case onlyForDownload 26 | case everything 27 | } 28 | 29 | /// A model for configuring parameters needed to check for app updates. 30 | /// 31 | /// Note: `tagName` is generally not needed, the SDK will identify the tag automatically. 32 | @objc 33 | public final class CheckForUpdateParams: NSObject { 34 | 35 | /// Create a new CheckForUpdateParams object. 36 | /// 37 | /// - Parameters: 38 | /// - apiKey: A `String` API key used for authentication. 39 | /// - tagName: An optional `String` that is the tag name used when this app was uploaded. 40 | /// - requiresLogin: A `Bool` indicating if user login is required before checking for updates. Defaults to `false`. 41 | /// - binaryIdentifierOverride: Override the binary identifier for local debugging 42 | /// - appIdOverride: Override the app identifier (Bundle Id) for local debugging 43 | @objc 44 | public init(apiKey: String, 45 | tagName: String? = nil, 46 | requiresLogin: Bool = false, 47 | binaryIdentifierOverride: String? = nil, 48 | appIdOverride: String? = nil) { 49 | self.apiKey = apiKey 50 | self.tagName = tagName 51 | self.loginSetting = requiresLogin ? .default : nil 52 | self.loginLevel = requiresLogin ? .everything : .noLogin 53 | self.binaryIdentifierOverride = binaryIdentifierOverride 54 | self.appIdOverride = appIdOverride 55 | } 56 | 57 | /// Create a new CheckForUpdateParams object with a connection name. 58 | /// 59 | /// - Parameters: 60 | /// - apiKey: A `String` API key used for authentication. 61 | /// - tagName: An optional `String` that is the tag name used when this app was uploaded. 62 | /// - connection: A `String` connection name for a company. Will automatically redirect login to the company’s SSO page. 63 | /// - loginLevel: An optional `LoginLevel` to set whether a login is required for downloading updates, checking for updates or never 64 | /// - binaryIdentifierOverride: Override the binary identifier for local debugging 65 | /// - appIdOverride: Override the app identifier (Bundle Id) for local debugging 66 | @objc 67 | public init(apiKey: String, 68 | tagName: String? = nil, 69 | connection: String, 70 | loginLevel: LoginLevel = .everything, 71 | binaryIdentifierOverride: String? = nil, 72 | appIdOverride: String? = nil) { 73 | self.apiKey = apiKey 74 | self.tagName = tagName 75 | self.loginSetting = .connection(connection) 76 | self.loginLevel = loginLevel 77 | self.binaryIdentifierOverride = binaryIdentifierOverride 78 | self.appIdOverride = appIdOverride 79 | } 80 | 81 | /// Create a new CheckForUpdateParams object with a login setting. 82 | /// 83 | /// - Parameters: 84 | /// - apiKey: A `String` API key used for authentication. 85 | /// - tagName: An optional `String` that is the tag name used when this app was uploaded. 86 | /// - loginSetting: A `LoginSetting` to require authenticated access to updates. 87 | /// - loginLevel: An optional `LoginLevel` to set whether a login is required for downloading updates, checking for updates or never 88 | /// - binaryIdentifierOverride: Override the binary identifier for local debugging 89 | /// - appIdOverride: Override the app identifier (Bundle Id) for local debugging 90 | public init(apiKey: String, 91 | tagName: String? = nil, 92 | loginSetting: LoginSetting, 93 | loginLevel: LoginLevel = .everything, 94 | binaryIdentifierOverride: String? = nil, 95 | appIdOverride: String? = nil) { 96 | self.apiKey = apiKey 97 | self.tagName = tagName 98 | self.loginSetting = loginSetting 99 | self.loginLevel = loginLevel 100 | self.binaryIdentifierOverride = binaryIdentifierOverride 101 | self.appIdOverride = appIdOverride 102 | } 103 | 104 | let apiKey: String 105 | let tagName: String? 106 | let loginSetting: LoginSetting? 107 | let loginLevel: LoginLevel? 108 | let binaryIdentifierOverride: String? 109 | let appIdOverride: String? 110 | } 111 | -------------------------------------------------------------------------------- /Sources/Models/DistributionReleaseInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DistributionReleaseInfo.swift 3 | // 4 | // 5 | // Created by Itay Brenner on 6/9/24. 6 | // 7 | 8 | import Foundation 9 | 10 | @objc 11 | public final class DistributionReleaseInfo: NSObject, Decodable, Sendable { 12 | public let id: String 13 | public let tag: String 14 | public let version: String 15 | public let build: String 16 | public let appId: String 17 | public let downloadUrl: String 18 | public let iconUrl: String? 19 | public let appName: String 20 | private let createdDate: String 21 | private let currentReleaseDate: String 22 | public let loginRequiredForDownload: Bool 23 | 24 | public var currentReleaseCreated: Date? { 25 | Date.fromString(currentReleaseDate) 26 | } 27 | 28 | public var created: Date? { 29 | Date.fromString(createdDate) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Models/DistributionUpdateCheckResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DistributionUpdateCheckResponse.swift 3 | // 4 | // 5 | // Created by Itay Brenner on 6/9/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct DistributionUpdateCheckResponse: Decodable { 11 | let updateInfo: DistributionReleaseInfo? 12 | } 13 | 14 | struct DistributionUpdateCheckErrorResponse: Decodable { 15 | let message: String 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Network/URLSession+Distribute.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLSession+Distribute.swift 3 | // 4 | // 5 | // Created by Itay Brenner on 6/9/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum RequestError: Error { 11 | case badRequest(String) 12 | case invalidData 13 | case loginRequired 14 | case unknownError 15 | } 16 | 17 | extension URLSession { 18 | func checkForUpdate(_ request: URLRequest, completion: @escaping @MainActor (Result) -> Void) { 19 | self.perform(request, decode: DistributionUpdateCheckResponse.self, useCamelCase: true, completion: completion) { [weak self] data, statusCode in 20 | return self?.getErrorFrom(data: data, statusCode: statusCode) ?? RequestError.badRequest("") 21 | } 22 | } 23 | 24 | func getAuthDataWith(_ request: URLRequest, completion: @escaping @MainActor (Result) -> Void) { 25 | self.perform(request, decode: AuthCodeResponse.self, useCamelCase: false, completion: completion) { _, _ in 26 | return RequestError.badRequest("") 27 | } 28 | } 29 | 30 | func refreshAccessToken(_ request: URLRequest, completion: @escaping @MainActor (Result) -> Void) { 31 | self.perform(request, decode: AuthRefreshResponse.self, useCamelCase: false, completion: completion) { _, _ in 32 | return RequestError.badRequest("") 33 | } 34 | } 35 | 36 | func getReleaseInfo(_ request: URLRequest, completion: @escaping @MainActor (Result) -> Void) { 37 | self.perform(request, decode: DistributionReleaseInfo.self, useCamelCase: true, completion: completion) { [weak self] data, statusCode in 38 | return self?.getErrorFrom(data: data, statusCode: statusCode) ?? RequestError.badRequest("") 39 | } 40 | } 41 | 42 | private func perform(_ request: URLRequest, 43 | decode decodable: T.Type, 44 | useCamelCase: Bool = true, 45 | completion: @escaping @MainActor (Result) -> Void, 46 | decodeErrorData: (@Sendable (Data, Int) -> Error)?) { 47 | URLSession.shared.dataTask(with: request) { (data, response, error) in 48 | var result: Result = .failure(RequestError.unknownError) 49 | defer { 50 | DispatchQueue.main.async { [result] in 51 | completion(result) 52 | } 53 | } 54 | if let error = error { 55 | result = .failure(error) 56 | return 57 | } 58 | guard let httpResponse = response as? HTTPURLResponse, 59 | let data = data else { 60 | result = .failure(RequestError.invalidData) 61 | return 62 | } 63 | guard (200...299).contains(httpResponse.statusCode) else { 64 | let error = decodeErrorData?(data, httpResponse.statusCode) ?? RequestError.badRequest("Unknown error") 65 | result = .failure(error) 66 | return 67 | } 68 | 69 | do { 70 | let jsonDecoder = JSONDecoder() 71 | jsonDecoder.keyDecodingStrategy = useCamelCase ? .useDefaultKeys : .convertFromSnakeCase 72 | result = .success(try jsonDecoder.decode(decodable, from: data)) 73 | } catch { 74 | result = .failure(error) 75 | } 76 | }.resume() 77 | } 78 | 79 | private func getErrorFrom(data: Data, statusCode: Int) -> RequestError { 80 | let errorMessage = ( 81 | try? JSONDecoder().decode( 82 | DistributionUpdateCheckErrorResponse.self, 83 | from: data 84 | ).message 85 | ) ?? "Unknown error" 86 | if statusCode == 403 { 87 | return RequestError.loginRequired 88 | } 89 | return RequestError.badRequest(errorMessage) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/UserAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserAction.swift 3 | // 4 | // 5 | // Created by Itay Brenner on 5/9/24. 6 | // 7 | 8 | import Foundation 9 | 10 | @objc 11 | public enum UserAction: Int { 12 | // User won't be notified of this update again 13 | case skip 14 | // User won't be notified of *any* update for 1 full day 15 | case postpone 16 | case install 17 | } 18 | -------------------------------------------------------------------------------- /Sources/UserDefaults+ETDistribution.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaults+ETDistribution.swift 3 | // 4 | // 5 | // Created by Itay Brenner on 7/9/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension UserDefaults { 11 | private enum Keys { 12 | static let skipedRelease = "skipedRelease" 13 | static let postponeTimeout = "postponeTimeout" 14 | } 15 | 16 | class var skippedRelease: String? { 17 | get { 18 | return UserDefaults(suiteName: "com.emerge.distribution")!.string(forKey: Keys.skipedRelease) 19 | } 20 | set { 21 | UserDefaults(suiteName: "com.emerge.distribution")!.set(newValue, forKey: Keys.skipedRelease) 22 | } 23 | } 24 | 25 | class var postponeTimeout: Date? { 26 | get { 27 | let epoch = UserDefaults(suiteName: "com.emerge.distribution")!.double(forKey: Keys.skipedRelease) 28 | return Date(timeIntervalSince1970: epoch) 29 | } 30 | set { 31 | UserDefaults(suiteName: "com.emerge.distribution")!.set(newValue?.timeIntervalSince1970, forKey: Keys.skipedRelease) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Utils/Date+Parse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // ETDistribution 4 | // 5 | // Created by Itay Brenner on 31/1/25. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Date { 11 | static func fromString(_ input: String) -> Date? { 12 | let formatter = ISO8601DateFormatter() 13 | formatter.formatOptions = [ .withInternetDateTime, .withFractionalSeconds ] 14 | return formatter.date(from: input) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/ViewController/UIViewController+Distribute.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewController+Distribute.swift 3 | // 4 | // 5 | // Created by Itay Brenner on 6/9/24. 6 | // 7 | 8 | import UIKit 9 | import Foundation 10 | 11 | struct AlertAction { 12 | let title: String 13 | let style: UIAlertAction.Style 14 | let handler: (UIAlertAction) -> Void 15 | } 16 | 17 | extension UIViewController { 18 | static func topMostViewController() -> UIViewController? { 19 | guard let rootViewController = UIApplication.shared.windows.first?.rootViewController else { 20 | return nil 21 | } 22 | return findTopViewController(rootViewController) 23 | } 24 | 25 | private static func findTopViewController(_ viewController: UIViewController) -> UIViewController { 26 | if let presentedViewController = viewController.presentedViewController { 27 | return findTopViewController(presentedViewController) 28 | } else if let navigationController = viewController as? UINavigationController { 29 | return findTopViewController(navigationController.visibleViewController ?? navigationController) 30 | } else if let tabBarController = viewController as? UITabBarController, 31 | let selectedViewController = tabBarController.selectedViewController { 32 | return findTopViewController(selectedViewController) 33 | } else { 34 | return viewController 35 | } 36 | } 37 | 38 | static func showAlert(title: String, message: String, actions: [AlertAction]) { 39 | guard let topViewController = topMostViewController() else { 40 | print("No top view controller found") 41 | return 42 | } 43 | 44 | let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) 45 | 46 | actions.forEach { action in 47 | alertController.addAction(UIAlertAction(title: action.title, style: action.style, handler: action.handler)) 48 | } 49 | 50 | topViewController.present(alertController, animated: true, completion: nil) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Tests/DistributionReleaseInfoTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // ETDistribution 4 | // 5 | // Created by Itay Brenner on 31/1/25. 6 | // 7 | 8 | import Testing 9 | import Foundation 10 | @testable import ETDistribution 11 | 12 | @Test func testParseDateWithFractionalSecondsWorks() async throws { 13 | let dateString = "2025-01-31T12:34:56.000Z" 14 | let expectedDate = Date(timeIntervalSince1970: 1738326896) // Friday, 31 January 2025 12:34:56 GMT 15 | let date = Date.fromString(dateString) 16 | 17 | #expect(date == expectedDate, "Invalid parser") 18 | } 19 | 20 | @Test func testParserUpToMiliseconds() async throws { 21 | let dateString = "2025-02-24T01:07:51.101Z" 22 | let date = Date.fromString(dateString) 23 | 24 | guard let date else { 25 | Issue.record("Failed to parse date") 26 | return 27 | } 28 | 29 | var calendar = Calendar.current 30 | calendar.timeZone = TimeZone(secondsFromGMT: 0)! 31 | let dateComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second, .nanosecond], from: date) 32 | 33 | #expect(dateComponents.year == 2025, "Different year") 34 | #expect(dateComponents.month == 2, "Different month") 35 | #expect(dateComponents.day == 24, "Different day") 36 | #expect(dateComponents.hour == 1, "Different hour") 37 | #expect(dateComponents.minute == 7, "Different minute") 38 | #expect(dateComponents.second == 51, "Different second") 39 | 40 | // Nanoseconds is the closest floating point number 41 | let miliseconds = dateComponents.nanosecond! / 1_000_000 42 | #expect(miliseconds == 101, "Different nanosecond") 43 | } 44 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd -P)" 6 | PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" 7 | 8 | PROJECT_BUILD_DIR="${PROJECT_BUILD_DIR:-"${PROJECT_ROOT}/build"}" 9 | XCODEBUILD_BUILD_DIR="$PROJECT_BUILD_DIR/xcodebuild" 10 | XCODEBUILD_DERIVED_DATA_PATH="$XCODEBUILD_BUILD_DIR/DerivedData" 11 | 12 | build_framework() { 13 | local sdk="$1" 14 | local destination="$2" 15 | local scheme="$3" 16 | 17 | local XCODEBUILD_ARCHIVE_PATH="./$scheme-$sdk.xcarchive" 18 | 19 | rm -rf "$XCODEBUILD_ARCHIVE_PATH" 20 | 21 | xcodebuild archive \ 22 | -scheme $scheme \ 23 | -archivePath $XCODEBUILD_ARCHIVE_PATH \ 24 | -derivedDataPath "$XCODEBUILD_DERIVED_DATA_PATH" \ 25 | -sdk "$sdk" \ 26 | -destination "$destination" \ 27 | BUILD_LIBRARY_FOR_DISTRIBUTION=YES \ 28 | INSTALL_PATH='Library/Frameworks' \ 29 | OTHER_SWIFT_FLAGS='-no-verify-emitted-module-interface -Xfrontend -module-interface-preserve-types-as-written' \ 30 | SWIFT_VERSION=${SWIFT_VERSION:-5.0} \ 31 | SWIFT_TREAT_WARNINGS_AS_ERRORS=YES \ 32 | GCC_TREAT_WARNINGS_AS_ERRORS=YES \ 33 | LD_GENERATE_MAP_FILE=YES 34 | 35 | FRAMEWORK_MODULES_PATH="$XCODEBUILD_ARCHIVE_PATH/Products/Library/Frameworks/$scheme.framework/Modules" 36 | mkdir -p "$FRAMEWORK_MODULES_PATH" 37 | cp -r \ 38 | "$XCODEBUILD_DERIVED_DATA_PATH/Build/Intermediates.noindex/ArchiveIntermediates/$scheme/BuildProductsPath/Release-$sdk/$scheme.swiftmodule" \ 39 | "$FRAMEWORK_MODULES_PATH/$scheme.swiftmodule" 40 | # Delete private swiftinterface 41 | rm -f "$FRAMEWORK_MODULES_PATH/$scheme.swiftmodule/*.private.swiftinterface" 42 | mkdir -p "$scheme-$sdk.xcarchive/LinkMaps" 43 | find "$XCODEBUILD_DERIVED_DATA_PATH" -name "$scheme-LinkMap-*.txt" -exec cp {} "./$scheme-$sdk.xcarchive/LinkMaps/" \; 44 | } 45 | 46 | build_framework "iphonesimulator" "generic/platform=iOS Simulator" "ETDistribution" 47 | build_framework "iphoneos" "generic/platform=iOS" "ETDistribution" 48 | 49 | echo "Builds completed successfully." 50 | 51 | rm -rf "ETDistribution.xcframework" 52 | xcodebuild -create-xcframework -framework ETDistribution-iphonesimulator.xcarchive/Products/Library/Frameworks/ETDistribution.framework -framework ETDistribution-iphoneos.xcarchive/Products/Library/Frameworks/ETDistribution.framework -output ETDistribution.xcframework 53 | 54 | cp -r ETDistribution-iphonesimulator.xcarchive/dSYMs ETDistribution.xcframework/ios-arm64_x86_64-simulator 55 | cp -r ETDistribution-iphoneos.xcarchive/dSYMs ETDistribution.xcframework/ios-arm64 --------------------------------------------------------------------------------