diff --git a/.github/workflows/FeatherQuill.yml b/.github/workflows/FeatherQuill.yml new file mode 100644 index 0000000..1ab2672 --- /dev/null +++ b/.github/workflows/FeatherQuill.yml @@ -0,0 +1,227 @@ +name: macOS +on: + push: + branches-ignore: + - '*WIP' +env: + PACKAGE_NAME: FeatherQuill +jobs: + build-ubuntu: + name: Build on Ubuntu + env: + SWIFT_VER: ${{ matrix.swift-version }} + runs-on: ${{ matrix.runs-on }} + if: "!contains(github.event.head_commit.message, 'ci skip')" + strategy: + matrix: + runs-on: [ubuntu-20.04, ubuntu-22.04] + swift-version: ["5.9", "5.9.2", "5.10"] + steps: + - uses: actions/checkout@v4 + - name: Set Ubuntu Release DOT + run: echo "RELEASE_DOT=$(lsb_release -sr)" >> $GITHUB_ENV + - name: Set Ubuntu Release NUM + run: echo "RELEASE_NUM=${RELEASE_DOT//[-._]/}" >> $GITHUB_ENV + - name: Set Ubuntu Codename + run: echo "RELEASE_NAME=$(lsb_release -sc)" >> $GITHUB_ENV + - name: Cache swift package modules + id: cache-spm-linux + uses: actions/cache@v4 + env: + cache-name: cache-spm + with: + path: .build + key: ${{ runner.os }}-${{ env.RELEASE_DOT }}-${{ env.cache-name }}-${{ matrix.swift-version }}-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ runner.os }}-${{ env.RELEASE_DOT }}-${{ env.cache-name }}-${{ matrix.swift-version }}- + ${{ runner.os }}-${{ env.RELEASE_DOT }}-${{ env.cache-name }}- + - name: Cache swift + id: cache-swift-linux + uses: actions/cache@v4 + env: + cache-name: cache-swift + with: + path: swift-${{ env.SWIFT_VER }}-RELEASE-ubuntu${{ env.RELEASE_DOT }} + key: ${{ runner.os }}-${{ env.cache-name }}-${{ matrix.swift-version }}-${{ env.RELEASE_DOT }} + restore-keys: | + ${{ runner.os }}-${{ env.cache-name }}-${{ matrix.swift-version }}- + - name: Download Swift + if: steps.cache-swift-linux.outputs.cache-hit != 'true' + run: curl -O https://download.swift.org/swift-${SWIFT_VER}-release/ubuntu${RELEASE_NUM}/swift-${SWIFT_VER}-RELEASE/swift-${SWIFT_VER}-RELEASE-ubuntu${RELEASE_DOT}.tar.gz + - name: Extract Swift + if: steps.cache-swift-linux.outputs.cache-hit != 'true' + run: tar xzf swift-${SWIFT_VER}-RELEASE-ubuntu${RELEASE_DOT}.tar.gz + - name: Add Path + run: echo "$GITHUB_WORKSPACE/swift-${SWIFT_VER}-RELEASE-ubuntu${RELEASE_DOT}/usr/bin" >> $GITHUB_PATH + - name: Test + run: swift test --enable-code-coverage + - uses: sersoft-gmbh/swift-coverage-action@v4 + id: coverage-files + with: + fail-on-empty-output: true + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + flags: swift-${{ matrix.swift-version }},ubuntu-${{ matrix.RELEASE_DOT }} + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + build-macos: + name: Build on macOS + runs-on: ${{ matrix.os }} + if: "!contains(github.event.head_commit.message, 'ci skip')" + strategy: + matrix: + include: + # - xcode: "/Applications/Xcode_15.0.1.app" + # os: macos-13 + # iOSVersion: "17.0.1" + # watchOSVersion: "10.0" + # watchName: "Apple Watch Series 9 (41mm)" + # iPhoneName: "iPhone 15" + # - xcode: "/Applications/Xcode_15.1.app" + # os: macos-13 + # iOSVersion: "17.2" + # watchOSVersion: "10.2" + # watchName: "Apple Watch Series 9 (45mm)" + # iPhoneName: "iPhone 15 Plus" + - xcode: "/Applications/Xcode_15.2.app" + os: macos-14 + iOSVersion: "17.2" + watchOSVersion: "10.2" + watchName: "Apple Watch Ultra (49mm)" + iPhoneName: "iPhone 15 Pro" + - xcode: "/Applications/Xcode_15.3.app" + os: macos-14 + iOSVersion: "17.4" + watchOSVersion: "10.4" + watchName: "Apple Watch Ultra 2 (49mm)" + iPhoneName: "iPhone 15 Pro Max" + steps: + - uses: actions/checkout@v4 + - name: Cache swift package modules + id: cache-spm-macos + uses: actions/cache@v4 + env: + cache-name: cache-spm + with: + path: .build + key: ${{ matrix.os }}-build-${{ env.cache-name }}-${{ matrix.xcode }}-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ matrix.os }}-build-${{ env.cache-name }}-${{ matrix.xcode }}- + - name: Cache mint + if: startsWith(matrix.xcode,'/Applications/Xcode_15.3') + id: cache-mint + uses: actions/cache@v4 + env: + cache-name: cache-mint + with: + path: .mint + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('Mintfile') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + - name: Set Xcode Name + run: echo "XCODE_NAME=$(basename -- ${{ matrix.xcode }} | sed 's/\.[^.]*$//' | cut -d'_' -f2)" >> $GITHUB_ENV + - name: Setup Xcode + run: sudo xcode-select -s ${{ matrix.xcode }}/Contents/Developer + - name: Install mint + if: startsWith(matrix.xcode,'/Applications/Xcode_15.3') + run: | + brew update + brew install mint + - name: Build + run: swift build + - name: Run Swift Package tests + run: swift test --enable-code-coverage + - uses: sersoft-gmbh/swift-coverage-action@v4 + id: coverage-files-spm + with: + fail-on-empty-output: true + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + files: ${{ join(fromJSON(steps.coverage-files-spm.outputs.files), ',') }} + token: ${{ secrets.CODECOV_TOKEN }} + flags: macOS,${{ env.XCODE_NAME }},${{ matrix.runs-on }} + - name: Clean up spm build directory + run: rm -rf .build + - name: Lint + run: ./scripts/lint.sh + if: startsWith(matrix.xcode,'/Applications/Xcode_15.3') + - name: Run iOS target tests + run: xcodebuild test -scheme ${{ env.PACKAGE_NAME }} -sdk "iphonesimulator" -destination 'platform=iOS Simulator,name=${{ matrix.iPhoneName }},OS=${{ matrix.iOSVersion }}' -enableCodeCoverage YES build test + - uses: sersoft-gmbh/swift-coverage-action@v4 + id: coverage-files-iOS + with: + fail-on-empty-output: true + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files-iOS.outputs.files), ',') }} + flags: iOS,iOS${{ matrix.iOSVersion }},macOS,${{ env.XCODE_NAME }} + - name: Run watchOS target tests + run: xcodebuild test -scheme ${{ env.PACKAGE_NAME }} -sdk "watchsimulator" -destination 'platform=watchOS Simulator,name=${{ matrix.watchName }},OS=${{ matrix.watchOSVersion }}' -enableCodeCoverage YES build test + - uses: sersoft-gmbh/swift-coverage-action@v4 + id: coverage-files-watchOS + with: + fail-on-empty-output: true + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files-watchOS.outputs.files), ',') }} + flags: watchOS,watchOS${{ matrix.watchOSVersion }},macOS,${{ env.XCODE_NAME }} + build-self: + name: Build on Self-Hosting macOS + runs-on: [self-hosted, macOS] + if: github.event.repository.owner.login == github.event.organization.login && !contains(github.event.head_commit.message, 'ci skip') + steps: + - uses: actions/checkout@v4 + - name: Cache swift package modules + id: cache-spm-macos + uses: actions/cache@v4 + env: + cache-name: cache-spm + with: + path: .build + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + - name: Cache mint + id: cache-mint + uses: actions/cache@v4 + env: + cache-name: cache-mint + with: + path: .mint + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('Mintfile') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + - name: Build + run: swift build + - name: Run Swift Package tests + run: swift test --enable-code-coverage + - uses: sersoft-gmbh/swift-coverage-action@v4 + with: + fail-on-empty-output: true + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: macOS,${{ env.XCODE_NAME }} + - name: Clean up spm build directory + run: rm -rf .build + - name: Lint + run: ./scripts/lint.sh diff --git a/.gitignore b/.gitignore index 330d167..1b15b2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,40 @@ +# Created by https://www.toptal.com/developers/gitignore/api/swift,swiftpm,swiftpackagemanager,xcode,macos +# Edit at https://www.toptal.com/developers/gitignore?templates=swift,swiftpm,swiftpackagemanager,xcode,macos + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Swift ### # Xcode # # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore @@ -40,11 +77,11 @@ playground.xcworkspace # Packages/ # Package.pins # Package.resolved -# *.xcodeproj +*.xcodeproj # # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata # hence it is not needed unless you have added a package configuration file to your project -# .swiftpm +.swiftpm .build/ @@ -88,3 +125,8 @@ fastlane/test_output # https://github.com/johnno1962/injectionforxcode iOSInjectionProject/ + +.mint +Output + +!Demo/FeatureFlagsApp.xcodeproj \ No newline at end of file diff --git a/.hound.yml b/.hound.yml new file mode 100644 index 0000000..6941f63 --- /dev/null +++ b/.hound.yml @@ -0,0 +1,2 @@ +swiftlint: + config_file: .swiftlint.yml diff --git a/.periphery.yml b/.periphery.yml new file mode 100644 index 0000000..85b884a --- /dev/null +++ b/.periphery.yml @@ -0,0 +1 @@ +retain_public: true diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..87b23ce --- /dev/null +++ b/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + - documentation_targets: [FeatherQuill] diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..f9ce5a9 --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +5.10 diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..6729548 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,7 @@ +--indent 2 +--header "\n .*?\.swift\n FeatherQuill\n\n Created by Leo Dion.\n Copyright © {year} BrightDigit.\n\n Permission is hereby granted, free of charge, to any person\n obtaining a copy of this software and associated documentation\n files (the “Software”), to deal in the Software without\n restriction, including without limitation the rights to use,\n copy, modify, merge, publish, distribute, sublicense, and/or\n sell copies of the Software, and to permit persons to whom the\n Software is furnished to do so, subject to the following\n conditions:\n \n The above copyright notice and this permission notice shall be\n included in all copies or substantial portions of the Software.\n\n THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,\n EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES\n OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\n NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT\n HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\n WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR\n OTHER DEALINGS IN THE SOFTWARE.\n" +--commas inline +--disable wrapMultilineStatementBraces, redundantInternal,redundantSelf,wrapMultilineStatementBraces,genericExtensions +--extensionacl on-declarations +--decimalgrouping 3,4 +--exclude .build, DerivedData, .swiftpm diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..9684b78 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,131 @@ +opt_in_rules: + - array_init + - closure_body_length + - closure_end_indentation + - closure_spacing + - collection_alignment + - conditional_returns_on_newline + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison + - convenience_type + - discouraged_object_literal + - discouraged_optional_boolean + - empty_collection_literal + - empty_count + - empty_string + - empty_xctest_method + - enum_case_associated_values_count + - expiring_todo + - explicit_acl + - explicit_init + - explicit_top_level_acl + # - fallthrough + - fatal_error_message + - file_name + - file_name_no_space + - file_types_order + - first_where + - flatmap_over_map_reduce + - force_unwrapping +# - function_default_parameter_at_end + - ibinspectable_in_extension + - identical_operands + - implicit_return + - implicitly_unwrapped_optional + - indentation_width + - joined_default_parameter + - last_where + - legacy_multiple + - legacy_random + - literal_expression_end_indentation + - lower_acl_than_parent + - missing_docs + - modifier_order + - multiline_arguments + - multiline_arguments_brackets + - multiline_function_chains + - multiline_literal_brackets + - multiline_parameters + - nimble_operator + - nslocalizedstring_key + - nslocalizedstring_require_bundle + - number_separator + - object_literal + - operator_usage_whitespace + - optional_enum_case_matching + - overridden_super_call + - override_in_extension + - pattern_matching_keywords + - prefer_self_type_over_type_of_self + - prefer_zero_over_explicit_init + - private_action + - private_outlet + - prohibited_interface_builder + - prohibited_super_call + - quick_discouraged_call + - quick_discouraged_focused_test + - quick_discouraged_pending_test + - reduce_into + - redundant_nil_coalescing + - redundant_type_annotation + - required_enum_case + - single_test_class + - sorted_first_last + - sorted_imports + - static_operator + - strong_iboutlet + - toggle_bool +# - trailing_closure + - type_contents_order + - unavailable_function + - unneeded_parentheses_in_closure_argument + - unowned_variable_capture + - untyped_error_in_catch + - vertical_parameter_alignment_on_call + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + - xct_specific_matcher + - yoda_condition +analyzer_rules: + - unused_import + - unused_declaration +cyclomatic_complexity: + - 6 + - 12 +type_body_length: + - 100 + - 200 +file_length: + warning: 215 + error: 300 +function_body_length: + - 18 + - 40 +function_parameter_count: 8 +line_length: + - 108 + - 200 +closure_body_length: + - 50 + - 60 +identifier_name: + excluded: + - id + - no +excluded: + - DerivedData + - .build + - .swiftpm + - Demo +indentation_width: + indentation_width: 2 +file_name: + severity: error +fatal_error_message: + severity: error +disabled_rules: + - nesting + - implicit_getter + - switch_case_alignment diff --git a/Demo/FeatureFlagsApp.xcodeproj/project.pbxproj b/Demo/FeatureFlagsApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..17f76e9 --- /dev/null +++ b/Demo/FeatureFlagsApp.xcodeproj/project.pbxproj @@ -0,0 +1,376 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + B387785F2BE9916B0063496A /* FeatureFlagsExample in Frameworks */ = {isa = PBXBuildFile; productRef = B387785E2BE9916B0063496A /* FeatureFlagsExample */; }; + B3E51F252BE7C6B90046D091 /* FeatureFlagsAppApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E51F242BE7C6B90046D091 /* FeatureFlagsAppApp.swift */; }; + B3E51F272BE7C6B90046D091 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E51F262BE7C6B90046D091 /* ContentView.swift */; }; + B3E51F292BE7C6BA0046D091 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B3E51F282BE7C6BA0046D091 /* Assets.xcassets */; }; + B3E51F2C2BE7C6BA0046D091 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B3E51F2B2BE7C6BA0046D091 /* Preview Assets.xcassets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + B3E51F212BE7C6B90046D091 /* FeatureFlagsApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FeatureFlagsApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B3E51F242BE7C6B90046D091 /* FeatureFlagsAppApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagsAppApp.swift; sourceTree = ""; }; + B3E51F262BE7C6B90046D091 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + B3E51F282BE7C6BA0046D091 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + B3E51F2B2BE7C6BA0046D091 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + B3E51F2D2BE7C6BA0046D091 /* FeatureFlagsApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FeatureFlagsApp.entitlements; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + B3E51F1E2BE7C6B90046D091 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B387785F2BE9916B0063496A /* FeatureFlagsExample in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + B38778602BE991D10063496A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + B3E51F182BE7C6B90046D091 = { + isa = PBXGroup; + children = ( + B3E51F232BE7C6B90046D091 /* FeatureFlagsApp */, + B3E51F222BE7C6B90046D091 /* Products */, + B38778602BE991D10063496A /* Frameworks */, + ); + sourceTree = ""; + }; + B3E51F222BE7C6B90046D091 /* Products */ = { + isa = PBXGroup; + children = ( + B3E51F212BE7C6B90046D091 /* FeatureFlagsApp.app */, + ); + name = Products; + sourceTree = ""; + }; + B3E51F232BE7C6B90046D091 /* FeatureFlagsApp */ = { + isa = PBXGroup; + children = ( + B3E51F242BE7C6B90046D091 /* FeatureFlagsAppApp.swift */, + B3E51F262BE7C6B90046D091 /* ContentView.swift */, + B3E51F282BE7C6BA0046D091 /* Assets.xcassets */, + B3E51F2D2BE7C6BA0046D091 /* FeatureFlagsApp.entitlements */, + B3E51F2A2BE7C6BA0046D091 /* Preview Content */, + ); + path = FeatureFlagsApp; + sourceTree = ""; + }; + B3E51F2A2BE7C6BA0046D091 /* Preview Content */ = { + isa = PBXGroup; + children = ( + B3E51F2B2BE7C6BA0046D091 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + B3E51F202BE7C6B90046D091 /* FeatureFlagsApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = B3E51F302BE7C6BA0046D091 /* Build configuration list for PBXNativeTarget "FeatureFlagsApp" */; + buildPhases = ( + B3E51F1D2BE7C6B90046D091 /* Sources */, + B3E51F1E2BE7C6B90046D091 /* Frameworks */, + B3E51F1F2BE7C6B90046D091 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = FeatureFlagsApp; + packageProductDependencies = ( + B387785E2BE9916B0063496A /* FeatureFlagsExample */, + ); + productName = FeatureFlagsApp; + productReference = B3E51F212BE7C6B90046D091 /* FeatureFlagsApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + B3E51F192BE7C6B90046D091 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1530; + LastUpgradeCheck = 1530; + TargetAttributes = { + B3E51F202BE7C6B90046D091 = { + CreatedOnToolsVersion = 15.3; + }; + }; + }; + buildConfigurationList = B3E51F1C2BE7C6B90046D091 /* Build configuration list for PBXProject "FeatureFlagsApp" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = B3E51F182BE7C6B90046D091; + packageReferences = ( + B387785D2BE9916B0063496A /* XCLocalSwiftPackageReference "FeatureFlagsExample" */, + ); + productRefGroup = B3E51F222BE7C6B90046D091 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + B3E51F202BE7C6B90046D091 /* FeatureFlagsApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + B3E51F1F2BE7C6B90046D091 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B3E51F2C2BE7C6BA0046D091 /* Preview Assets.xcassets in Resources */, + B3E51F292BE7C6BA0046D091 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + B3E51F1D2BE7C6B90046D091 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B3E51F272BE7C6B90046D091 /* ContentView.swift in Sources */, + B3E51F252BE7C6B90046D091 /* FeatureFlagsAppApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + B3E51F2E2BE7C6BA0046D091 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 14.4; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + B3E51F2F2BE7C6BA0046D091 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 14.4; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + B3E51F312BE7C6BA0046D091 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = FeatureFlagsApp/FeatureFlagsApp.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"FeatureFlagsApp/Preview Content\""; + DEVELOPMENT_TEAM = MLT7M394S7; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.brightdigit.FeatureFlagsApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + B3E51F322BE7C6BA0046D091 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = FeatureFlagsApp/FeatureFlagsApp.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"FeatureFlagsApp/Preview Content\""; + DEVELOPMENT_TEAM = MLT7M394S7; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.brightdigit.FeatureFlagsApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + B3E51F1C2BE7C6B90046D091 /* Build configuration list for PBXProject "FeatureFlagsApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B3E51F2E2BE7C6BA0046D091 /* Debug */, + B3E51F2F2BE7C6BA0046D091 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B3E51F302BE7C6BA0046D091 /* Build configuration list for PBXNativeTarget "FeatureFlagsApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B3E51F312BE7C6BA0046D091 /* Debug */, + B3E51F322BE7C6BA0046D091 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + B387785D2BE9916B0063496A /* XCLocalSwiftPackageReference "FeatureFlagsExample" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = FeatureFlagsExample; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + B387785E2BE9916B0063496A /* FeatureFlagsExample */ = { + isa = XCSwiftPackageProductDependency; + productName = FeatureFlagsExample; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = B3E51F192BE7C6B90046D091 /* Project object */; +} diff --git a/Demo/FeatureFlagsApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Demo/FeatureFlagsApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Demo/FeatureFlagsApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Demo/FeatureFlagsApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Demo/FeatureFlagsApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Demo/FeatureFlagsApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Demo/FeatureFlagsApp/Assets.xcassets/AccentColor.colorset/Contents.json b/Demo/FeatureFlagsApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Demo/FeatureFlagsApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Demo/FeatureFlagsApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/Demo/FeatureFlagsApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3f00db4 --- /dev/null +++ b/Demo/FeatureFlagsApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Demo/FeatureFlagsApp/Assets.xcassets/Contents.json b/Demo/FeatureFlagsApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Demo/FeatureFlagsApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Demo/FeatureFlagsApp/ContentView.swift b/Demo/FeatureFlagsApp/ContentView.swift new file mode 100644 index 0000000..1293ef8 --- /dev/null +++ b/Demo/FeatureFlagsApp/ContentView.swift @@ -0,0 +1,52 @@ +// +// ContentView.swift +// FeatherQuill +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import FeatureFlagsExample +import SwiftUI + +struct ContentView: View { + @Environment(\.newDesign) var newDesign + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + + Toggle("Is Enabled", isOn: newDesign.bindingValue) + .disabled(!newDesign.isAvailable) + .opacity(newDesign.isAvailable ? 1.0 : 0.5) + } + .padding() + } +} + +#Preview { + ContentView() +} diff --git a/Demo/FeatureFlagsApp/FeatureFlagsApp.entitlements b/Demo/FeatureFlagsApp/FeatureFlagsApp.entitlements new file mode 100644 index 0000000..18aff0c --- /dev/null +++ b/Demo/FeatureFlagsApp/FeatureFlagsApp.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + + diff --git a/Demo/FeatureFlagsApp/FeatureFlagsAppApp.swift b/Demo/FeatureFlagsApp/FeatureFlagsAppApp.swift new file mode 100644 index 0000000..ec11d23 --- /dev/null +++ b/Demo/FeatureFlagsApp/FeatureFlagsAppApp.swift @@ -0,0 +1,39 @@ +// +// FeatureFlagsAppApp.swift +// FeatherQuill +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SwiftUI + +@main +struct FeatureFlagsAppApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/Demo/FeatureFlagsApp/Preview Content/Preview Assets.xcassets/Contents.json b/Demo/FeatureFlagsApp/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Demo/FeatureFlagsApp/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Demo/FeatureFlagsExample/.gitignore b/Demo/FeatureFlagsExample/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/Demo/FeatureFlagsExample/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Demo/FeatureFlagsExample/Package.swift b/Demo/FeatureFlagsExample/Package.swift new file mode 100644 index 0000000..dea7435 --- /dev/null +++ b/Demo/FeatureFlagsExample/Package.swift @@ -0,0 +1,38 @@ +// swift-tools-version: 5.10 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "FeatureFlagsExample", + platforms: [ + .iOS(.v17), + .macCatalyst(.v17), + .macOS(.v14), + .tvOS(.v17), + .visionOS(.v1), + .watchOS(.v10) + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "FeatureFlagsExample", + targets: ["FeatureFlagsExample"] + ) + ], + dependencies: [ + .package(name: "FeatherQuill", path: "../..") + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "FeatureFlagsExample", + dependencies: [.product(name: "FeatherQuill", package: "FeatherQuill")] + ), + .testTarget( + name: "FeatureFlagsExampleTests", + dependencies: ["FeatureFlagsExample"] + ) + ] +) diff --git a/Demo/FeatureFlagsExample/Sources/FeatureFlagsExample/AudienceType.swift b/Demo/FeatureFlagsExample/Sources/FeatureFlagsExample/AudienceType.swift new file mode 100644 index 0000000..0c6aa39 --- /dev/null +++ b/Demo/FeatureFlagsExample/Sources/FeatureFlagsExample/AudienceType.swift @@ -0,0 +1,47 @@ +// +// AudienceType.swift +// FeatherQuill +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import FeatherQuill +import Foundation + +public struct AudienceType: UserType { + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public var rawValue: Int + + public typealias RawValue = Int + + public static let proSubscriber: AudienceType = .init(rawValue: 1) + public static let testFlightBeta: AudienceType = .init(rawValue: 2) + public static let any: AudienceType = .init(rawValue: .max) + public static let `default`: AudienceType = [.testFlightBeta, proSubscriber] + public static let none: AudienceType = [] +} diff --git a/Demo/FeatureFlagsExample/Sources/FeatureFlagsExample/NewDesignFeature.swift b/Demo/FeatureFlagsExample/Sources/FeatureFlagsExample/NewDesignFeature.swift new file mode 100644 index 0000000..813b089 --- /dev/null +++ b/Demo/FeatureFlagsExample/Sources/FeatureFlagsExample/NewDesignFeature.swift @@ -0,0 +1,52 @@ +// +// NewDesignFeature.swift +// FeatherQuill +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import FeatherQuill +import Foundation +import SwiftUI + +struct NewDesignFeature: FeatureFlag { + typealias UserTypeValue = AudienceType + + static let probability: Double = 0.5 + static let initialValue = false + + static func evaluateUser(_ userType: AudienceType) async -> Bool { + guard userType.rawValue > 0 else { + return false + } + let value: Bool = .random() + print("User Matches: \(value)") + return value + } +} + +extension EnvironmentValues { + public var newDesign: Feature { self[NewDesignFeature.self] } +} diff --git a/Demo/FeatureFlagsExample/Tests/FeatureFlagsExampleTests/FeatureFlagsExampleTests.swift b/Demo/FeatureFlagsExample/Tests/FeatureFlagsExampleTests/FeatureFlagsExampleTests.swift new file mode 100644 index 0000000..7f08fce --- /dev/null +++ b/Demo/FeatureFlagsExample/Tests/FeatureFlagsExampleTests/FeatureFlagsExampleTests.swift @@ -0,0 +1,41 @@ +// +// FeatureFlagsExampleTests.swift +// FeatherQuill +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +@testable import FeatureFlagsExample +import XCTest + +final class FeatureFlagsExampleTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +} diff --git a/Mintfile b/Mintfile new file mode 100644 index 0000000..44e3750 --- /dev/null +++ b/Mintfile @@ -0,0 +1,3 @@ +nicklockwood/SwiftFormat@0.53.5 +realm/SwiftLint@0.54.0 +peripheryapp/periphery@2.18.0 \ No newline at end of file diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..950341e --- /dev/null +++ b/Package.swift @@ -0,0 +1,41 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +// swiftlint:disable:next explicit_top_level_acl explicit_acl +let package = Package( + name: "FeatherQuill", + platforms: [ + .iOS(.v17), + .macCatalyst(.v17), + .macOS(.v14), + .tvOS(.v17), + .visionOS(.v1), + .watchOS(.v10) + ], + products: [ + .library( + name: "FeatherQuill", + targets: ["FeatherQuill"] + ) + ], + targets: [ + .target( + name: "FeatherQuill", + swiftSettings: [ + SwiftSetting.enableUpcomingFeature("BareSlashRegexLiterals"), + SwiftSetting.enableUpcomingFeature("ConciseMagicFile"), + SwiftSetting.enableUpcomingFeature("ExistentialAny"), + SwiftSetting.enableUpcomingFeature("ForwardTrailingClosures"), + SwiftSetting.enableUpcomingFeature("ImplicitOpenExistentials"), + SwiftSetting.enableUpcomingFeature("DisableOutwardActorInference"), + SwiftSetting.enableExperimentalFeature("StrictConcurrency"), + SwiftSetting.unsafeFlags(["-warn-concurrency", "-enable-actor-data-race-checks"]) + ] + ), + .testTarget( + name: "FeatherQuillTests", + dependencies: ["FeatherQuill"] + ) + ] +) diff --git a/README.md b/README.md index dbc7eee..78104d8 100644 --- a/README.md +++ b/README.md @@ -1 +1,135 @@ -# FeatherQuill \ No newline at end of file +# FeatherQuill + +Easily rollout your new features to segments of your audience. + +[![SwiftPM](https://img.shields.io/badge/SPM-Linux%20%7C%20iOS%20%7C%20macOS%20%7C%20watchOS%20%7C%20tvOS-success?logo=swift)](https://swift.org) +[![Twitter](https://img.shields.io/badge/twitter-@brightdigit-blue.svg?style=flat)](http://twitter.com/brightdigit) +![GitHub](https://img.shields.io/github/license/brightdigit/FeatherQuill) +![GitHub issues](https://img.shields.io/github/issues/brightdigit/FeatherQuill) +![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/brightdigit/FeatherQuill/FeatherQuill.yml?label=actions&logo=github&?branch=main) + +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fbrightdigit%2FFeatherQuill%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/brightdigit/FeatherQuill) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fbrightdigit%2FFeatherQuill%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/brightdigit/FeatherQuill) + +[![Codecov](https://img.shields.io/codecov/c/github/brightdigit/FeatherQuill)](https://codecov.io/gh/brightdigit/FeatherQuill) +[![CodeFactor Grade](https://img.shields.io/codefactor/grade/github/brightdigit/FeatherQuill)](https://www.codefactor.io/repository/github/brightdigit/FeatherQuill) +[![codebeat badge](https://codebeat.co/badges/94a8313d-2215-4ef6-8690-ab7b3e06369c)](https://codebeat.co/projects/github-com-brightdigit-mistkit-main) +[![Code Climate maintainability](https://img.shields.io/codeclimate/maintainability/brightdigit/FeatherQuill)](https://codeclimate.com/github/brightdigit/FeatherQuill) +[![Code Climate technical debt](https://img.shields.io/codeclimate/tech-debt/brightdigit/FeatherQuill?label=debt)](https://codeclimate.com/github/brightdigit/FeatherQuill) +[![Code Climate issues](https://img.shields.io/codeclimate/issues/brightdigit/FeatherQuill)](https://codeclimate.com/github/brightdigit/FeatherQuill) +[![Reviewed by Hound](https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg)](https://houndci.com) + +## Features + +FeatherQuill is a Swift package that provides a mechanism for implementing offline feature flags in your application. Feature flags allow you to enable or disable features for different users or segments of your user base without requiring a server-side update. This can be useful for A/B testing, rollout strategies, and more. + +* **Offline Support:** Feature flags are stored locally on the device, so they can be used even when the device is not connected to the internet. +* **Audience Targeting:** You can target feature flags to specific users or segments of users based on criteria such as user ID or device type. +* **Metrics Collection:** FeatherQuill can collect metrics on how feature flags are being used, which can be helpful for evaluating the effectiveness of your A/B tests or other rollout strategies. + +## Requirements + +**Apple Platforms** + +- Xcode 15.3 or later +- Swift 5.10 or later +- iOS 17 / watchOS 10 / tvOS 17 / visionOS 1 / macCatalyst 17 / macOS 14 or later deployment targets + +**Linux** + +- Ubuntu 20.04 or later +- Swift 5.10 or later + +## Installation + +To add the FeatherQuill package to your Xcode project, select File > Swift Packages > Add Package Dependency and enter the repository URL. + +Using Swift Package Manager add the repository url: + +```swift +dependencies: [ + .package(url: "https://github.com/brightdigit/FeatherQuill", from: "1.0.0-alpha.1") +] +``` + +## Usage + +```swift +import FeatherQuill + +// Initialize the client with your bundle identifier as the domain +let plausible = Plausible(domain: "com.example.yourApp") + +// Define an event +let event = Event(url: "app://localhost/login") + +// Send the event +plausible.send(event: event) +``` + +### `Plausible` Client + +`Plausible` is a client for interacting with the Plausible API. It is initialized with a domain, which is typically your app's bundle identifier. The `Plausible` client is used to send events to the Plausible API for tracking and analysis. + +To construct a `Plausible` instance, you need to provide a domain. The domain is a string that identifies your application, typically the bundle identifier of your app. + +```swift +let plausible = Plausible(domain: "com.example.yourApp") +``` + +By default `Plausible` uses a `URLSessionTransport`, however you can use alternatives such as AsyncClient. + +### Sending an `Event` + +`Event` represents an event in your system. An event has a name, and optionally, a domain, URL, referrer, custom properties (`props`), and revenue information. You can create an `Event` instance and send it using the `Plausible` client. + +To construct an `Event`, you need to provide at least a name. The name is a string that identifies the event you want to track. Optionally, you can also provide: + +- **`name`** string that represents the name of the event. _Default_ is **pageview**. +- **`url`** string that represents the URL where the event occurred. For an app you may wish to use a app url such as `app://localhost/login`. +- `domain` _optional_ string that identifies the domain in which the event occurred. Overrides whatever was set in the `Plausible` instance. +- `referrer` _optional_ string that represents the URL of the referrer +- `props` _optional_ dictionary of custom properties associated with the event. +- `revenue` _optional_ `Revenue` instance that represents the revenue data associated with the event + +```swift +let event = Event + name: "eventName", + domain: "domain", + url: "url", + referrer: "referrer", + props: ["key": "value"], + revenue: Revenue( + currencyCode: "USD", + amount: 100 + ) +) +``` + +FeatherQuill provides two ways to send events to the Plausible API: + +#### Asynchronous Throwing Method + +This method sends an event to the Plausible API and throws an error if the operation fails. This is useful when you want to handle errors in your own way. Here's an example: + +```swift +do { + try await plausible.postEvent(event) +} catch { + print("Failed to post event: \(error)") +} +``` + +#### Synchronous Method + +This method sends an event to the Plausible API in the background and ignores any errors that occur. This is useful when you don't need to handle errors and want to fire-and-forget the event. Here's an example: + +```swift +plausible.postEvent(event) +``` + +In both cases, `event` is an instance of `Event` that you want to send to the Plausible API. + +## License + +FeatherQuill is available under the MIT license. See the [LICENSE](LICENSE) file for more info. \ No newline at end of file diff --git a/Scripts/gh-md-toc b/Scripts/gh-md-toc new file mode 100755 index 0000000..8d35839 --- /dev/null +++ b/Scripts/gh-md-toc @@ -0,0 +1,411 @@ +#!/usr/bin/env bash + +# +# Steps: +# +# 1. Download corresponding html file for some README.md: +# curl -s $1 +# +# 2. Discard rows where no substring 'user-content-' (github's markup): +# awk '/user-content-/ { ... +# +# 3.1 Get last number in each row like ' ... sitemap.js.*<\/h/)+2, RLENGTH-5) +# +# 5. Find anchor and insert it inside "(...)": +# substr($0, match($0, "href=\"[^\"]+?\" ")+6, RLENGTH-8) +# + +gh_toc_version="0.8.0" + +gh_user_agent="gh-md-toc v$gh_toc_version" + +# +# Download rendered into html README.md by its url. +# +# +gh_toc_load() { + local gh_url=$1 + + if type curl &>/dev/null; then + curl --user-agent "$gh_user_agent" -s "$gh_url" + elif type wget &>/dev/null; then + wget --user-agent="$gh_user_agent" -qO- "$gh_url" + else + echo "Please, install 'curl' or 'wget' and try again." + exit 1 + fi +} + +# +# Converts local md file into html by GitHub +# +# -> curl -X POST --data '{"text": "Hello world github/linguist#1 **cool**, and #1!"}' https://api.github.com/markdown +#

Hello world github/linguist#1 cool, and #1!

'" +gh_toc_md2html() { + local gh_file_md=$1 + local skip_header=$2 + + URL=https://api.github.com/markdown/raw + + if [ ! -z "$GH_TOC_TOKEN" ]; then + TOKEN=$GH_TOC_TOKEN + else + TOKEN_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/token.txt" + if [ -f "$TOKEN_FILE" ]; then + TOKEN="$(cat $TOKEN_FILE)" + fi + fi + if [ ! -z "${TOKEN}" ]; then + AUTHORIZATION="Authorization: token ${TOKEN}" + fi + + local gh_tmp_file_md=$gh_file_md + if [ "$skip_header" = "yes" ]; then + if grep -Fxq "" $gh_src; then + # cut everything before the toc + gh_tmp_file_md=$gh_file_md~~ + sed '1,//d' $gh_file_md > $gh_tmp_file_md + fi + fi + + # echo $URL 1>&2 + OUTPUT=$(curl -s \ + --user-agent "$gh_user_agent" \ + --data-binary @"$gh_tmp_file_md" \ + -H "Content-Type:text/plain" \ + -H "$AUTHORIZATION" \ + "$URL") + + rm -f $gh_file_md~~ + + if [ "$?" != "0" ]; then + echo "XXNetworkErrorXX" + fi + if [ "$(echo "${OUTPUT}" | awk '/API rate limit exceeded/')" != "" ]; then + echo "XXRateLimitXX" + else + echo "${OUTPUT}" + fi +} + + +# +# Is passed string url +# +gh_is_url() { + case $1 in + https* | http*) + echo "yes";; + *) + echo "no";; + esac +} + +# +# TOC generator +# +gh_toc(){ + local gh_src=$1 + local gh_src_copy=$1 + local gh_ttl_docs=$2 + local need_replace=$3 + local no_backup=$4 + local no_footer=$5 + local indent=$6 + local skip_header=$7 + + if [ "$gh_src" = "" ]; then + echo "Please, enter URL or local path for a README.md" + exit 1 + fi + + + # Show "TOC" string only if working with one document + if [ "$gh_ttl_docs" = "1" ]; then + + echo "Table of Contents" + echo "=================" + echo "" + gh_src_copy="" + + fi + + if [ "$(gh_is_url "$gh_src")" == "yes" ]; then + gh_toc_load "$gh_src" | gh_toc_grab "$gh_src_copy" "$indent" + if [ "${PIPESTATUS[0]}" != "0" ]; then + echo "Could not load remote document." + echo "Please check your url or network connectivity" + exit 1 + fi + if [ "$need_replace" = "yes" ]; then + echo + echo "!! '$gh_src' is not a local file" + echo "!! Can't insert the TOC into it." + echo + fi + else + local rawhtml=$(gh_toc_md2html "$gh_src" "$skip_header") + if [ "$rawhtml" == "XXNetworkErrorXX" ]; then + echo "Parsing local markdown file requires access to github API" + echo "Please make sure curl is installed and check your network connectivity" + exit 1 + fi + if [ "$rawhtml" == "XXRateLimitXX" ]; then + echo "Parsing local markdown file requires access to github API" + echo "Error: You exceeded the hourly limit. See: https://developer.github.com/v3/#rate-limiting" + TOKEN_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/token.txt" + echo "or place GitHub auth token here: ${TOKEN_FILE}" + exit 1 + fi + local toc=`echo "$rawhtml" | gh_toc_grab "$gh_src_copy" "$indent"` + echo "$toc" + if [ "$need_replace" = "yes" ]; then + if grep -Fxq "" $gh_src && grep -Fxq "" $gh_src; then + echo "Found markers" + else + echo "You don't have or in your file...exiting" + exit 1 + fi + local ts="<\!--ts-->" + local te="<\!--te-->" + local dt=`date +'%F_%H%M%S'` + local ext=".orig.${dt}" + local toc_path="${gh_src}.toc.${dt}" + local toc_createdby="" + local toc_footer="" + # http://fahdshariff.blogspot.ru/2012/12/sed-mutli-line-replacement-between-two.html + # clear old TOC + sed -i${ext} "/${ts}/,/${te}/{//!d;}" "$gh_src" + # create toc file + echo "${toc}" > "${toc_path}" + if [ "${no_footer}" != "yes" ]; then + echo -e "\n${toc_createdby}\n${toc_footer}\n" >> "$toc_path" + fi + + # insert toc file + if ! sed --version > /dev/null 2>&1; then + sed -i "" "/${ts}/r ${toc_path}" "$gh_src" + else + sed -i "/${ts}/r ${toc_path}" "$gh_src" + fi + echo + if [ "${no_backup}" = "yes" ]; then + rm ${toc_path} ${gh_src}${ext} + fi + echo "!! TOC was added into: '$gh_src'" + if [ -z "${no_backup}" ]; then + echo "!! Origin version of the file: '${gh_src}${ext}'" + echo "!! TOC added into a separate file: '${toc_path}'" + fi + echo + fi + fi +} + +# +# Grabber of the TOC from rendered html +# +# $1 - a source url of document. +# It's need if TOC is generated for multiple documents. +# $2 - number of spaces used to indent. +# +gh_toc_grab() { + common_awk_script=' + modified_href = "" + split(href, chars, "") + for (i=1;i <= length(href); i++) { + c = chars[i] + res = "" + if (c == "+") { + res = " " + } else { + if (c == "%") { + res = "\\x" + } else { + res = c "" + } + } + modified_href = modified_href res + } + print sprintf("%*s", (level-1)*'"$2"', "") "* [" text "](" gh_url modified_href ")" + ' + if [ `uname -s` == "OS/390" ]; then + grepcmd="pcregrep -o" + echoargs="" + awkscript='{ + level = substr($0, length($0), 1) + text = substr($0, match($0, /a>.*<\/h/)+2, RLENGTH-5) + href = substr($0, match($0, "href=\"([^\"]+)?\"")+6, RLENGTH-7) + '"$common_awk_script"' + }' + else + grepcmd="grep -Eo" + echoargs="-e" + awkscript='{ + level = substr($0, length($0), 1) + text = substr($0, match($0, /a>.*<\/h/)+2, RLENGTH-5) + href = substr($0, match($0, "href=\"[^\"]+?\"")+6, RLENGTH-7) + '"$common_awk_script"' + }' + fi + href_regex='href=\"[^\"]+?\"' + + # if closed is on the new line, then move it on the prev line + # for example: + # was: The command foo1 + # + # became: The command foo1 + sed -e ':a' -e 'N' -e '$!ba' -e 's/\n<\/h/<\/h/g' | + + # find strings that corresponds to template + $grepcmd '//g' | sed 's/<\/code>//g' | + + # remove g-emoji + sed 's/]*[^<]*<\/g-emoji> //g' | + + # now all rows are like: + # ... /dev/null`; then + echo `$tool --version | head -n 1` + else + echo "not installed" + fi + done +} + +show_help() { + local app_name=$(basename "$0") + echo "GitHub TOC generator ($app_name): $gh_toc_version" + echo "" + echo "Usage:" + echo " $app_name [options] src [src] Create TOC for a README file (url or local path)" + echo " $app_name - Create TOC for markdown from STDIN" + echo " $app_name --help Show help" + echo " $app_name --version Show version" + echo "" + echo "Options:" + echo " --indent Set indent size. Default: 3." + echo " --insert Insert new TOC into original file. For local files only. Default: false." + echo " See https://github.com/ekalinin/github-markdown-toc/issues/41 for details." + echo " --no-backup Remove backup file. Set --insert as well. Default: false." + echo " --hide-footer Do not write date & author of the last TOC update. Set --insert as well. Default: false." + echo " --skip-header Hide entry of the topmost headlines. Default: false." + echo " See https://github.com/ekalinin/github-markdown-toc/issues/125 for details." + echo "" +} + +# +# Options handlers +# +gh_toc_app() { + local need_replace="no" + local indent=3 + + if [ "$1" = '--help' ] || [ $# -eq 0 ] ; then + show_help + return + fi + + if [ "$1" = '--version' ]; then + show_version + return + fi + + if [ "$1" = '--indent' ]; then + indent="$2" + shift 2 + fi + + if [ "$1" = "-" ]; then + if [ -z "$TMPDIR" ]; then + TMPDIR="/tmp" + elif [ -n "$TMPDIR" -a ! -d "$TMPDIR" ]; then + mkdir -p "$TMPDIR" + fi + local gh_tmp_md + if [ `uname -s` == "OS/390" ]; then + local timestamp=$(date +%m%d%Y%H%M%S) + gh_tmp_md="$TMPDIR/tmp.$timestamp" + else + gh_tmp_md=$(mktemp $TMPDIR/tmp.XXXXXX) + fi + while read input; do + echo "$input" >> "$gh_tmp_md" + done + gh_toc_md2html "$gh_tmp_md" | gh_toc_grab "" "$indent" + return + fi + + if [ "$1" = '--insert' ]; then + need_replace="yes" + shift + fi + + if [ "$1" = '--no-backup' ]; then + need_replace="yes" + no_backup="yes" + shift + fi + + if [ "$1" = '--hide-footer' ]; then + need_replace="yes" + no_footer="yes" + shift + fi + + if [ "$1" = '--skip-header' ]; then + skip_header="yes" + shift + fi + + + for md in "$@" + do + echo "" + gh_toc "$md" "$#" "$need_replace" "$no_backup" "$no_footer" "$indent" "$skip_header" + done + + echo "" + echo "" +} + +# +# Entry point +# +gh_toc_app "$@" diff --git a/Scripts/lint.sh b/Scripts/lint.sh new file mode 100755 index 0000000..d30285e --- /dev/null +++ b/Scripts/lint.sh @@ -0,0 +1,45 @@ +#!/bin/sh + +if [ -z "$SRCROOT" ]; then + SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + PACKAGE_DIR="${SCRIPT_DIR}/.." +else + PACKAGE_DIR="${SRCROOT}" +fi + +if [ -z "$GITHUB_ACTION" ]; then + MINT_CMD="/opt/homebrew/bin/mint" +else + MINT_CMD="mint" +fi + +export MINT_PATH="$PACKAGE_DIR/.mint" +MINT_ARGS="-n -m $PACKAGE_DIR/Mintfile --silent" +MINT_RUN="$MINT_CMD run $MINT_ARGS" + +pushd $PACKAGE_DIR + +$MINT_CMD bootstrap -m Mintfile + +if [ "$LINT_MODE" == "NONE" ]; then + exit +elif [ "$LINT_MODE" == "STRICT" ]; then + SWIFTFORMAT_OPTIONS="" + SWIFTLINT_OPTIONS="--strict" +else + SWIFTFORMAT_OPTIONS="" + SWIFTLINT_OPTIONS="" +fi + +pushd $PACKAGE_DIR + +if [ -z "$CI" ]; then + $MINT_RUN swiftformat . + $MINT_RUN swiftlint --fix +fi + +$MINT_RUN periphery scan +$MINT_RUN swiftformat --lint $SWIFTFORMAT_OPTIONS . +$MINT_RUN swiftlint lint $SWIFTLINT_OPTIONS + +popd diff --git a/Sources/FeatherQuill/AvailabilityOptions.swift b/Sources/FeatherQuill/AvailabilityOptions.swift new file mode 100644 index 0000000..94ce8ab --- /dev/null +++ b/Sources/FeatherQuill/AvailabilityOptions.swift @@ -0,0 +1,44 @@ +// +// AvailabilityOptions.swift +// FeatherQuill +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +public struct AvailabilityOptions: OptionSet, Sendable { + public typealias RawValue = Int + + public static let `default`: AvailabilityOptions = .init() + public static let allowOverwriteAvailable: AvailabilityOptions = .init(rawValue: 1) + public static let disableUpdateAvailability: AvailabilityOptions = .init(rawValue: 2) + + public var rawValue: Int + + public init(rawValue: RawValue) { + self.rawValue = rawValue + } +} diff --git a/Sources/FeatherQuill/Feature.swift b/Sources/FeatherQuill/Feature.swift new file mode 100644 index 0000000..47c3b1d --- /dev/null +++ b/Sources/FeatherQuill/Feature.swift @@ -0,0 +1,84 @@ +// +// Feature.swift +// FeatherQuill +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + import Observation + import SwiftUI + + /// Set of values for the Feature. + @Observable + public class Feature { + private let featureValue: FeatureValue + private let availability: FeatureAvailability + + /// Binding value to use for SwiftUI Views. + public var bindingValue: Binding { + featureValue.bindingValue + } + + /// Value of the Feature. + public var value: ValueType { + featureValue.value + } + + /// Whether the feature is available to the user + public var isAvailable: Bool { + availability.value + } + + fileprivate init( + value: FeatureValue, + availability: FeatureAvailability + ) { + featureValue = value + self.availability = availability + } + } + + extension Feature { + internal convenience init( + key: String, + defaultValue: ValueType, + userType: UserTypeValue, + probability: Double = 0.0, + options: AvailabilityOptions = [], + _ audienceCallback: @Sendable @escaping (UserTypeValue) async -> Bool + ) { + let value: FeatureValue = .init(key: key, defaultValue: defaultValue) + let availablity: FeatureAvailability = .init( + key: key, + userType: userType, + probability: probability, + options: options, + audienceCallback + ) + self.init(value: value, availability: availablity) + } + } +#endif diff --git a/Sources/FeatherQuill/FeatureAvailability.swift b/Sources/FeatherQuill/FeatureAvailability.swift new file mode 100644 index 0000000..939abf7 --- /dev/null +++ b/Sources/FeatherQuill/FeatureAvailability.swift @@ -0,0 +1,129 @@ +// +// FeatureAvailability.swift +// FeatherQuill +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +internal struct FeatureAvailability: Sendable { + private let userDefaults: UserDefaults + private let metricsKey: String + private let availabilityKey: String + private let options: AvailabilityOptions + private let metrics: FeatureAvailabilityMetrics + + internal var value: Bool { + assert((userDefaults.object(forKey: availabilityKey) as? Bool) != nil) + return userDefaults.bool(forKey: availabilityKey) + } + + private init( + userDefaults: UserDefaults, + metricsKey: String, + availabilityKey: String, + options: AvailabilityOptions, + metrics: FeatureAvailabilityMetrics + ) { + self.userDefaults = userDefaults + self.metricsKey = metricsKey + self.availabilityKey = availabilityKey + self.options = options + self.metrics = metrics + } + + internal init( + key: String, + userType: UserTypeValue, + probability: Double = 0.0, + userDefaults: UserDefaults = .standard, + options: AvailabilityOptions = [], + _ availability: @Sendable @escaping (UserTypeValue) async -> Bool + ) { + let metricsKey = [ + FeatureFlags.rootKey, key, FeatureFlags.metricsKey + ].joined(separator: ".") + let availabilityKey = [ + FeatureFlags.rootKey, key, FeatureFlags.isAvailableKey + ].joined(separator: ".") + self.init( + userDefaults: userDefaults, + metricsKey: metricsKey, + availabilityKey: availabilityKey, + options: options, + metrics: .init(userType: userType, probability: probability) + ) + initialize(with: availability) + } + + private func initializeMetrics() -> Bool { + guard !options.contains(.disableUpdateAvailability) else { + return false + } + + if let oldMetrics: FeatureAvailabilityMetrics = + self.userDefaults.metrics(forKey: self.metricsKey) { + guard metrics != oldMetrics else { + return false + } + } + + userDefaults.set(metrics, forKey: metricsKey) + return true + } + + private func initializeAvailability( + with audienceCallback: @Sendable @escaping (UserTypeValue) async -> Bool, + force: Bool = false + ) { + let isAvailable = userDefaults.object(forKey: availabilityKey).map { _ in + userDefaults.bool(forKey: availabilityKey) + } + switch (isAvailable, force, options.contains(.allowOverwriteAvailable)) { + case (true, _, false): + return + case (.some, false, _): + return + + case (.none, _, _): + break + case (_, true, _): + break + } + + Task { + let value = await metrics.calculateAvailability(audienceCallback) + userDefaults.set(value, forKey: availabilityKey) + } + } + + private func initialize(with audienceCallback: @Sendable @escaping (UserTypeValue) async -> Bool) { + let metricsHaveChanged = initializeMetrics() + initializeAvailability(with: audienceCallback, force: metricsHaveChanged) + } +} + +extension UserDefaults: @unchecked Sendable {} diff --git a/Sources/FeatherQuill/FeatureAvailabilityMetrics.swift b/Sources/FeatherQuill/FeatureAvailabilityMetrics.swift new file mode 100644 index 0000000..d724563 --- /dev/null +++ b/Sources/FeatherQuill/FeatureAvailabilityMetrics.swift @@ -0,0 +1,85 @@ +// +// FeatureAvailabilityMetrics.swift +// FeatherQuill +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +internal struct FeatureAvailabilityMetrics: Equatable, Sendable { + private let userType: UserTypeValue + private let probability: Double + + fileprivate var value: Double { + Double(userType.rawValue) + probability.truncatingRemainder(dividingBy: 1) + } + + fileprivate init(value: Double) { + let rawValueDouble = floor(value) + let rawValue = UserTypeValue.RawValue(rawValueDouble) + let probability = (value - rawValueDouble) + self.init(userType: .init(rawValue: rawValue), probability: probability) + } + + internal init(userType: UserTypeValue, probability: Double) { + self.userType = userType + self.probability = Self.roundProbability(probability) + } + + internal static func roundProbability(_ value: Double) -> Double { + assert(value <= 1.0) + return (value * 1_000).rounded() / 1_000.0 + } + + internal func calculateAvailability( + _ audienceCallback: @Sendable @escaping (UserTypeValue) async -> Bool + ) async -> Bool { + let value: Bool + if await audienceCallback(userType) { + value = true + } else { + let randomValue: Double = .random(in: 0.0 ..< 1.0) + value = randomValue <= probability + } + return value + } +} + +extension UserDefaults { + internal func set(_ value: FeatureAvailabilityMetrics, forKey key: String) { + set(value.value, forKey: key) + } + + internal func metrics( + forKey key: String + ) -> FeatureAvailabilityMetrics? { + guard object(forKey: key) != nil else { + return nil + } + let value: Double = double(forKey: key) + return .init(value: value) + } +} diff --git a/Sources/FeatherQuill/FeatureFlag.swift b/Sources/FeatherQuill/FeatureFlag.swift new file mode 100644 index 0000000..87830c4 --- /dev/null +++ b/Sources/FeatherQuill/FeatureFlag.swift @@ -0,0 +1,98 @@ +// +// FeatureFlag.swift +// FeatherQuill +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + import SwiftUI + + private enum FeatureFlagSuffixes { + static let values: [String] = [ + "FeatureFlag", + "Feature" + ] + private static func dropCount(from typeName: String) -> Int? { + values.first(where: typeName.hasSuffix(_:)).map(\.count) + } + + static func key(from typeName: String) -> String { + guard let dropCount = dropCount(from: typeName) else { + return typeName + } + return .init(typeName.dropLast(dropCount)) + } + } + + public protocol FeatureFlag: EnvironmentKey + where Value == FeatherQuill.Feature { + associatedtype ValueType = Bool + associatedtype UserTypeValue: UserType + + typealias Feature = FeatherQuill.Feature + + static var key: String { get } + static var audience: UserTypeValue { get } + static var probability: Double { get } + static var initialValue: ValueType { get } + static var options: AvailabilityOptions { get } + + @Sendable + static func evaluateUser(_ userType: UserTypeValue) async -> Bool + } + + extension FeatureFlag { + private static var typeName: String { + "\(Self.self)" + } + + /// Matching user for ``FeatureFlag + public static var audience: UserTypeValue { + .default + } + + /// Behavior options on how to handle changes. + public static var options: AvailabilityOptions { + .default + } + + /// The key the ``FeatureFlag``. + public static var key: String { + FeatureFlagSuffixes.key(from: typeName) + } + + /// The default value for the environment key. + public static var defaultValue: FeatherQuill.Feature { + .init( + key: key, + defaultValue: initialValue, + userType: audience, + probability: probability, + evaluateUser + ) + } + } +#endif diff --git a/Sources/FeatherQuill/FeatureFlags.swift b/Sources/FeatherQuill/FeatureFlags.swift new file mode 100644 index 0000000..acfdf0d --- /dev/null +++ b/Sources/FeatherQuill/FeatureFlags.swift @@ -0,0 +1,36 @@ +// +// FeatureFlags.swift +// FeatherQuill +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal enum FeatureFlags { + internal static let rootKey = "FeatureFlags" + internal static let valueKey = "Value" + + internal static let metricsKey = "AvailbilityMetrics" + internal static let isAvailableKey = "IsAvailable" +} diff --git a/Sources/FeatherQuill/FeatureValue.swift b/Sources/FeatherQuill/FeatureValue.swift new file mode 100644 index 0000000..65f8e23 --- /dev/null +++ b/Sources/FeatherQuill/FeatureValue.swift @@ -0,0 +1,84 @@ +// +// FeatureValue.swift +// FeatherQuill +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + import Foundation + import Observation + import SwiftUI + + @Observable + internal class FeatureValue { + private let userDefaults: UserDefaults + private let key: String + private let fullKey: String + internal var bindingValue: Binding { + .init { + self._storedValue + } set: { value in + self._storedValue = value + } + } + + internal var value: ValueType { + let value = userDefaults.value(forKey: fullKey) as? ValueType + assert(value != nil) + return value ?? _storedValue + } + + private var _storedValue: ValueType { + didSet { + print("didSet \(_storedValue)") + userDefaults.setValue(_storedValue, forKey: fullKey) + userDefaults.synchronize() + print("set \(_storedValue)") + } + } + + internal init( + key: String, + defaultValue: ValueType, + userDefaults: UserDefaults = .standard + ) { + self.userDefaults = userDefaults + self.key = key + let initialValue: ValueType + let fullKey = [ + FeatureFlags.rootKey, self.key, FeatureFlags.valueKey + ].joined(separator: ".") + self.fullKey = fullKey + if let currentValue = userDefaults.value(forKey: fullKey) as? ValueType { + initialValue = currentValue + } else { + userDefaults.setValue(defaultValue, forKey: fullKey) + initialValue = defaultValue + } + _storedValue = initialValue + } + } +#endif diff --git a/Sources/FeatherQuill/UserType.swift b/Sources/FeatherQuill/UserType.swift new file mode 100644 index 0000000..c578f7b --- /dev/null +++ b/Sources/FeatherQuill/UserType.swift @@ -0,0 +1,32 @@ +// +// UserType.swift +// FeatherQuill +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public protocol UserType: OptionSet, Sendable where Self.RawValue: BinaryInteger { + static var `default`: Self { get } +} diff --git a/Tests/FeatherQuillTests/AudienceType.swift b/Tests/FeatherQuillTests/AudienceType.swift new file mode 100644 index 0000000..6cbef23 --- /dev/null +++ b/Tests/FeatherQuillTests/AudienceType.swift @@ -0,0 +1,52 @@ +// +// AudienceType.swift +// FeatherQuill +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import FeatherQuill + +public struct AudienceType: UserType { + public typealias RawValue = Int + public static let proSubscriber: AudienceType = .init(rawValue: 1) + public static let testFlightBeta: AudienceType = .init(rawValue: 2) + public static let any: AudienceType = .init(rawValue: .max) + public static let `default`: AudienceType = [.testFlightBeta, proSubscriber] + public static let none: AudienceType = [] + public var rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static func includes(_ value: AudienceType) -> Bool { + guard value.rawValue > 0 else { + return false + } + let value: Bool = .random() + return value + } +} diff --git a/Tests/FeatherQuillTests/FeatureAvailabilityMetricsTests.swift b/Tests/FeatherQuillTests/FeatureAvailabilityMetricsTests.swift new file mode 100644 index 0000000..c80ac11 --- /dev/null +++ b/Tests/FeatherQuillTests/FeatureAvailabilityMetricsTests.swift @@ -0,0 +1,45 @@ +// +// FeatureAvailabilityMetricsTests.swift +// FeatherQuill +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +@testable import FeatherQuill +import XCTest + +internal final class FeatureAvailabilityMetricsTests: XCTestCase { + internal func testUserDefaultsMetrics() { + let expected = FeatureAvailabilityMetrics( + userType: AudienceType.proSubscriber, + probability: .random(in: 0 ..< 1) + ) + UserDefaults.standard.set(expected, forKey: "testMetrics") + let actual: FeatureAvailabilityMetrics? = + UserDefaults.standard.metrics(forKey: "testMetrics") + + XCTAssertEqual(expected, actual) + } +} diff --git a/Tests/FeatherQuillTests/FeatureFlagTests.swift b/Tests/FeatherQuillTests/FeatureFlagTests.swift new file mode 100644 index 0000000..1555814 --- /dev/null +++ b/Tests/FeatherQuillTests/FeatureFlagTests.swift @@ -0,0 +1,48 @@ +// +// FeatureFlagTests.swift +// FeatherQuill +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import XCTest + +internal final class FeatureFlagTests: XCTestCase { + internal func testFlag() throws { + #if canImport(SwiftUI) + // swiftlint:disable:next force_unwrapping + let domain = Bundle.main.bundleIdentifier! + UserDefaults.standard.removePersistentDomain(forName: domain) + XCTAssertEqual(MockFeatureFlag.key, "Mock") + let defaultMock = MockFeatureFlag.defaultValue + XCTAssertEqual( + defaultMock.value, MockFeatureFlag.initialValue + ) + #else + throw XCTSkip("Not suported outside of SwiftUI.") + + #endif + } +} diff --git a/Tests/FeatherQuillTests/FeatureTests.swift b/Tests/FeatherQuillTests/FeatureTests.swift new file mode 100644 index 0000000..c23e0da --- /dev/null +++ b/Tests/FeatherQuillTests/FeatureTests.swift @@ -0,0 +1,55 @@ +// +// FeatureTests.swift +// FeatherQuill +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +@testable import FeatherQuill +import XCTest + +internal final class FeatureTests: XCTestCase { + internal func testWrapped() throws { + #if canImport(SwiftUI) + let key = UUID().uuidString + let expectedValue = Int.random(in: 100 ... 1_000) + let feature = Feature( + key: key, + defaultValue: 0, + userType: AudienceType.default + ) { _ in true } + + let fullKey = [ + FeatureFlags.rootKey, key, FeatureFlags.valueKey + ].joined(separator: ".") + feature.bindingValue.wrappedValue = expectedValue + let actualValue = UserDefaults.standard.integer(forKey: fullKey) + XCTAssertEqual(actualValue, expectedValue) + #else + throw XCTSkip("Not suported outside of SwiftUI.") + + #endif + } +} diff --git a/Tests/FeatherQuillTests/MockFeatureFlag.swift b/Tests/FeatherQuillTests/MockFeatureFlag.swift new file mode 100644 index 0000000..74aac17 --- /dev/null +++ b/Tests/FeatherQuillTests/MockFeatureFlag.swift @@ -0,0 +1,44 @@ +// +// MockFeatureFlag.swift +// FeatherQuill +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftUI) + import FeatherQuill + + internal struct MockFeatureFlag: FeatureFlag { + internal typealias UserTypeValue = AudienceType + + internal static let initialValue: Int = .random(in: 1_000 ... 9_999) + + internal static let probability: Double = .random(in: 0 ..< 1) + + internal static func evaluateUser(_: AudienceType) async -> Bool { + true + } + } +#endif diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..951b97b --- /dev/null +++ b/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - "Tests" diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..e00d87e --- /dev/null +++ b/project.yml @@ -0,0 +1,16 @@ +name: FeatherQuill +settings: + LINT_MODE: ${LINT_MODE} +packages: + FeatherQuill: + path: . +projectReferences: + Demo: + path: ./Demo/FeatureFlagsApp.xcodeproj +aggregateTargets: + Lint: + buildScripts: + - path: Scripts/lint.sh + name: Lint + basedOnDependencyAnalysis: false + schemes: {} \ No newline at end of file