Repository: shapehq/spices Branch: main Commit: bb50b8335b7e Files: 87 Total size: 152.9 KB Directory structure: gitextract_vzwnjo9a/ ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── feature_request.yml │ │ └── question.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── build.yml │ ├── build_documentation.yml │ ├── build_example_project.yml │ ├── codeql.yml │ ├── swiftlint.yml │ └── test.yml ├── .gitignore ├── .spi.yml ├── .swiftlint.yml ├── Examples/ │ ├── Example.xcodeproj/ │ │ ├── project.pbxproj │ │ ├── project.xcworkspace/ │ │ │ └── contents.xcworkspacedata │ │ └── xcshareddata/ │ │ └── xcschemes/ │ │ └── SwiftUIExample.xcscheme │ ├── SwiftUIExample/ │ │ ├── AppSpiceStore.swift │ │ ├── Assets.xcassets/ │ │ │ ├── AccentColor.colorset/ │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset/ │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── ContentView.swift │ │ ├── ExampleApp.swift │ │ └── Preview Content/ │ │ └── Preview Assets.xcassets/ │ │ └── Contents.json │ └── UIKitExample/ │ ├── AppDelegate.swift │ ├── AppSpiceStore.swift │ ├── Assets.xcassets/ │ │ ├── AccentColor.colorset/ │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset/ │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Base.lproj/ │ │ └── LaunchScreen.storyboard │ ├── ContentViewController.swift │ ├── Info.plist │ └── SceneDelegate.swift ├── LICENSE ├── Package.swift ├── README.md ├── SECURITY.md ├── Sources/ │ └── Spices/ │ ├── Internal/ │ │ ├── MenuItems/ │ │ │ ├── AsyncButtonMenuItem.swift │ │ │ ├── ButtonMenuItem.swift │ │ │ ├── ChildSpiceStoreMenuItem.swift │ │ │ ├── MenuItem.swift │ │ │ ├── MenuItemProvider.swift │ │ │ ├── PickerMenuItem.swift │ │ │ ├── TextFieldMenuItem.swift │ │ │ ├── ToggleMenuItem.swift │ │ │ └── ViewMenuItem.swift │ │ ├── Name.swift │ │ ├── Preparable.swift │ │ ├── Storage/ │ │ │ ├── AnyStorage.swift │ │ │ ├── Storage.swift │ │ │ ├── ThrowingStorage.swift │ │ │ └── UserDefaultsStorage.swift │ │ ├── String+Helpers.swift │ │ ├── UIApplication+Helpers.swift │ │ ├── UIViewController+Helpers.swift │ │ ├── View+RestartOnChange.swift │ │ └── Views/ │ │ ├── AsyncButtonMenuItemView.swift │ │ ├── ButtonMenuItemView.swift │ │ ├── ChildSpiceStoreMenuItemView.swift │ │ ├── MenuItemListContent.swift │ │ ├── MenuItemListView.swift │ │ ├── MenuItemView.swift │ │ ├── PickerMenuItemView.swift │ │ ├── TextFieldMenuItemView.swift │ │ ├── ToggleMenuItemView.swift │ │ ├── UserInteraction.swift │ │ ├── View+ConfigureSheetPresentation.swift │ │ ├── View+ErrorAlert.swift │ │ └── ViewMenuItemView.swift │ ├── PresentationStyle.swift │ ├── Spice.swift │ ├── SpiceEditor.swift │ ├── SpiceEditorViewController.swift │ ├── SpiceEditorWindow.swift │ ├── SpiceStore.swift │ ├── Spices.docc/ │ │ ├── Extensions/ │ │ │ └── SpiceStore.md │ │ └── Spices.md │ ├── SpicesTitleProvider.swift │ └── View+SpiceEditor.swift └── Tests/ └── SpicesTests/ ├── AnyStorageTests.swift ├── CamelCaseToNaturalTextTests.swift ├── Helpers/ │ └── UserDefaults+Helpers.swift ├── Mocks/ │ ├── MockEnvironment.swift │ ├── MockSpiceStore.swift │ └── MockStorage.swift ├── SpiceTests.swift └── UserDefaultsStorageTests.swift ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/CODEOWNERS ================================================ # https://help.github.com/en/articles/about-code-owners @shapehq/devops ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: 🐛 Bug Report description: File a bug report. labels: ["bug"] body: - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report! - type: textarea id: bug-description attributes: label: What happened? description: Please describe the bug. placeholder: Description of the bug. validations: required: true - type: textarea id: steps-to-reproduce attributes: label: What are the steps to reproduce? description: Please describe the steps to reproduce the bug. placeholder: | Step 1: ... Step 2: ... Step 3: ... validations: required: true - type: textarea id: expected-behavior attributes: label: What is the expected behavior? description: Please describe the behavior you expect of Spices. placeholder: I expect that Spices would... validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: ✨ Feature Request description: Suggest an idea for this project labels: ["feature"] body: - type: markdown attributes: value: | Thanks for taking the time to fill out this feature request! - type: textarea id: problem attributes: label: Is your feature request related to a problem? description: A clear and concise description of what the problem is. placeholder: Yes, the problem is that... validations: required: true - type: textarea id: solution attributes: label: What solution would you like? description: A clear and concise description of what you want to happen. placeholder: I would like that... validations: required: true - type: textarea id: alternatives attributes: label: What alternatives have you considered? description: A clear and concise description of any alternative solutions or features you've considered. placeholder: I have considered to... validations: required: true - type: textarea id: context attributes: label: Any additional context? description: Add any other context or screenshots about the feature request here. ================================================ FILE: .github/ISSUE_TEMPLATE/question.yml ================================================ name: ❓ Question description: Ask a question about this project labels: ["question"] body: - type: textarea id: question attributes: label: Your Question description: A clear and concisely formulated question. placeholder: I'd like to ask... validations: required: true - type: textarea id: context attributes: label: Any additional context? description: Add any other context or screenshots about the question request here. ================================================ FILE: .github/pull_request_template.md ================================================ ## Description ## Motivation and Context ## Screenshots (if appropriate): ## Types of changes - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) ================================================ FILE: .github/workflows/build.yml ================================================ name: Build on: workflow_dispatch: {} pull_request: types: [opened, synchronize] permissions: read-all concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true env: XCODE_SCHEME: Spices jobs: build: name: Build runs-on: macos-15 strategy: matrix: include: - xcode: 16.4 destination: iPhone 16 Pro os: 18.5 env: DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer steps: - name: Checkout uses: actions/checkout@v4 - name: Build run: | set -o pipefail &&\ xcodebuild build\ -scheme ${{ env.XCODE_SCHEME }}\ -sdk iphonesimulator\ -destination "platform=iOS Simulator,name=${{ matrix.destination }},OS=${{ matrix.os }}"\ | xcbeautify --renderer github-actions ================================================ FILE: .github/workflows/build_documentation.yml ================================================ name: Build Documentation on: workflow_dispatch: {} pull_request: branches: - main permissions: read-all concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true env: XCODE_SCHEME: Spices jobs: build: name: Build Documentation runs-on: macos-15 strategy: matrix: include: - xcode: 16.4 env: DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer steps: - name: Checkout uses: actions/checkout@v4 - name: Build Documentation run: | set -o pipefail &&\ xcodebuild docbuild\ -scheme ${{ env.XCODE_SCHEME }}\ -destination 'generic/platform=iOS'\ -derivedDataPath .derived-data\ | xcbeautify --renderer github-actions ================================================ FILE: .github/workflows/build_example_project.yml ================================================ name: Build Example Project on: workflow_dispatch: {} pull_request: types: [opened, synchronize] permissions: read-all concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true env: XCODE_PROJECT_PATH: Examples/Example.xcodeproj SWIFTUI_XCODE_SCHEME: SwiftUIExample UIKIT_XCODE_SCHEME: UIKitExample jobs: build_swiftui: name: Build SwiftUI Example Project runs-on: macos-15 strategy: matrix: include: - xcode: 16.4 destination: iPhone 16 Pro os: 18.5 env: DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer steps: - name: Checkout uses: actions/checkout@v4 - name: Build run: | set -o pipefail &&\ xcodebuild build\ -project ${{ env.XCODE_PROJECT_PATH }}\ -scheme ${{ env.SWIFTUI_XCODE_SCHEME }}\ -sdk iphonesimulator\ -destination "platform=iOS Simulator,name=${{ matrix.destination }},OS=${{ matrix.os }}"\ | xcbeautify --renderer github-actions build_uikit: name: Build UIKit Example Project runs-on: macos-15 continue-on-error: true strategy: matrix: include: - xcode: 16.4 destination: iPhone 16 Pro os: 18.5 env: DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer steps: - name: Checkout uses: actions/checkout@v4 - name: Build run: | set -o pipefail &&\ xcodebuild build\ -project ${{ env.XCODE_PROJECT_PATH }}\ -scheme ${{ env.UIKIT_XCODE_SCHEME }}\ -sdk iphonesimulator\ -destination "platform=iOS Simulator,name=${{ matrix.destination }},OS=${{ matrix.os }}"\ | xcbeautify --renderer github-actions ================================================ FILE: .github/workflows/codeql.yml ================================================ name: CodeQL on: push: branches: [ "main" ] pull_request: branches: [ "main" ] schedule: - cron: '19 15 * * 1' permissions: read-all concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true env: PACKAGE_XCODE_SCHEME: Spices SWIFTUI_EXAMPLE_XCODE_SCHEME: SwiftUIExample UIKIT_EXAMPLE_XCODE_SCHEME: UIKitExample EXAMPLE_XCODE_PROJECT_PATH: Examples/Example.xcodeproj BUILD_DESTINATION: platform=iOS Simulator,name=iPhone 16,OS=18.5 DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer jobs: analyze: name: Analyze (${{ matrix.language }}) runs-on: ${{ (matrix.language == 'swift' && 'macos-15') || 'ubuntu-latest' }} permissions: security-events: write packages: read actions: read contents: read strategy: fail-fast: false matrix: include: - language: actions build-mode: none - language: swift build-mode: manual steps: - name: Checkout repository uses: actions/checkout@v4 - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Build Swift Package if: matrix.build-mode == 'manual' && matrix.language == 'swift' shell: bash run: | set -o pipefail &&\ xcodebuild build-for-testing\ -scheme ${{ env.PACKAGE_XCODE_SCHEME }}\ -sdk iphonesimulator\ -destination "${{ env.BUILD_DESTINATION }}"\ | xcbeautify --renderer github-actions - name: Build SwiftUI Example Project if: matrix.build-mode == 'manual' && matrix.language == 'swift' shell: bash run: | set -o pipefail &&\ xcodebuild build\ -project ${{ env.EXAMPLE_XCODE_PROJECT_PATH }}\ -scheme ${{ env.SWIFTUI_EXAMPLE_XCODE_SCHEME }}\ -sdk iphonesimulator\ -destination "${{ env.BUILD_DESTINATION }}"\ | xcbeautify --renderer github-actions - name: Build UIKit Example Project if: matrix.build-mode == 'manual' && matrix.language == 'swift' shell: bash run: | set -o pipefail &&\ xcodebuild build\ -project ${{ env.EXAMPLE_XCODE_PROJECT_PATH }}\ -scheme ${{ env.UIKIT_EXAMPLE_XCODE_SCHEME }}\ -sdk iphonesimulator\ -destination "${{ env.BUILD_DESTINATION }}"\ | xcbeautify --renderer github-actions - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" ================================================ FILE: .github/workflows/swiftlint.yml ================================================ name: SwiftLint on: workflow_dispatch: {} pull_request: types: [opened, synchronize] permissions: read-all concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: SwiftLint: runs-on: macos-13 steps: - name: Checkout uses: actions/checkout@v4 with: submodules: recursive - name: Run SwiftLint run: swiftlint --quiet --reporter github-actions-logging ================================================ FILE: .github/workflows/test.yml ================================================ name: Run Tests on: workflow_dispatch: pull_request: types: [opened, synchronize] permissions: read-all env: XCODE_SCHEME: Spices jobs: run-tests: name: Run Tests runs-on: macos-15 strategy: matrix: include: - xcode: 16.4 destination: iPhone 16 Pro os: 18.5 env: DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer steps: - name: Checkout Repository uses: actions/checkout@v4 - name: Test run: | set -o pipefail &&\ xcodebuild -scheme ${{ env.XCODE_SCHEME }} test\ -destination "platform=iOS Simulator,name=${{ matrix.destination }},OS=${{ matrix.os }}"\ | xcbeautify --renderer github-actions ================================================ FILE: .gitignore ================================================ .DS_Store .swiftpm .build xcuserdata ================================================ FILE: .spi.yml ================================================ version: 1 builder: configs: - documentation_targets: [Spices] platform: ios ================================================ FILE: .swiftlint.yml ================================================ disabled_rules: - nesting opt_in_rules: - anonymous_argument_in_multiline_closure - array_init - 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 - discarded_notification_center_observer - discouraged_assert - discouraged_none_name - discouraged_object_literal - discouraged_optional_boolean - empty_collection_literal - empty_count - empty_string - empty_xctest_method - explicit_init - fallthrough - fatal_error_message - file_name_no_space - first_where - flatmap_over_map_reduce - identical_operands - implicitly_unwrapped_optional - joined_default_parameter - last_where - literal_expression_end_indentation - lower_acl_than_parent - modifier_order - number_separator - operator_usage_whitespace - overridden_super_call - pattern_matching_keywords - prefer_self_in_static_references - prefer_self_type_over_type_of_self - prefer_zero_over_explicit_init - prohibited_interface_builder - prohibited_super_call - reduce_into - redundant_nil_coalescing - redundant_type_annotation - single_test_class - sorted_first_last - sorted_imports - static_operator - switch_case_on_newline - test_case_accessibility - toggle_bool - trailing_closure - unneeded_parentheses_in_closure_argument - untyped_error_in_catch - vertical_parameter_alignment_on_call - yoda_condition analyzer_rules: - capture_variable line_length: warning: 150 error: 175 ignores_comments: true type_name: max_length: warning: 60 error: 60 identifier_name: max_length: warning: 60 error: 60 min_length: warning: 1 ================================================ FILE: Examples/Example.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 77; objects = { /* Begin PBXBuildFile section */ 72FF7CD92D6602DE00483C43 /* Spices in Frameworks */ = {isa = PBXBuildFile; productRef = 72FF7CD82D6602DE00483C43 /* Spices */; }; 72FF7CDB2D6602E300483C43 /* Spices in Frameworks */ = {isa = PBXBuildFile; productRef = 72FF7CDA2D6602E300483C43 /* Spices */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 725C9D2E2D5E26F100C79FDC /* SwiftUIExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftUIExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 728FD6682D649EC8006B0CB2 /* UIKitExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = UIKitExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 728FD67B2D649EC9006B0CB2 /* Exceptions for "UIKitExample" folder in "UIKitExample" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, ); target = 728FD6672D649EC8006B0CB2 /* UIKitExample */; }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ 725C9D302D5E26F100C79FDC /* SwiftUIExample */ = { isa = PBXFileSystemSynchronizedRootGroup; path = SwiftUIExample; sourceTree = ""; }; 728FD6692D649EC8006B0CB2 /* UIKitExample */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( 728FD67B2D649EC9006B0CB2 /* Exceptions for "UIKitExample" folder in "UIKitExample" target */, ); path = UIKitExample; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ 725C9D2B2D5E26F100C79FDC /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 72FF7CD92D6602DE00483C43 /* Spices in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; 728FD6652D649EC8006B0CB2 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 72FF7CDB2D6602E300483C43 /* Spices in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 725C9D252D5E26F100C79FDC = { isa = PBXGroup; children = ( 725C9D302D5E26F100C79FDC /* SwiftUIExample */, 728FD6692D649EC8006B0CB2 /* UIKitExample */, 728FD69A2D64AB1D006B0CB2 /* Frameworks */, 725C9D2F2D5E26F100C79FDC /* Products */, ); sourceTree = ""; }; 725C9D2F2D5E26F100C79FDC /* Products */ = { isa = PBXGroup; children = ( 725C9D2E2D5E26F100C79FDC /* SwiftUIExample.app */, 728FD6682D649EC8006B0CB2 /* UIKitExample.app */, ); name = Products; sourceTree = ""; }; 728FD69A2D64AB1D006B0CB2 /* Frameworks */ = { isa = PBXGroup; children = ( ); name = Frameworks; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 725C9D2D2D5E26F100C79FDC /* SwiftUIExample */ = { isa = PBXNativeTarget; buildConfigurationList = 725C9D3C2D5E26F200C79FDC /* Build configuration list for PBXNativeTarget "SwiftUIExample" */; buildPhases = ( 725C9D2A2D5E26F100C79FDC /* Sources */, 725C9D2B2D5E26F100C79FDC /* Frameworks */, 725C9D2C2D5E26F100C79FDC /* Resources */, 72FF7BB02D65B97C00483C43 /* Run SwiftLint */, ); buildRules = ( ); dependencies = ( ); fileSystemSynchronizedGroups = ( 725C9D302D5E26F100C79FDC /* SwiftUIExample */, ); name = SwiftUIExample; packageProductDependencies = ( 72FF7CD82D6602DE00483C43 /* Spices */, ); productName = Spices; productReference = 725C9D2E2D5E26F100C79FDC /* SwiftUIExample.app */; productType = "com.apple.product-type.application"; }; 728FD6672D649EC8006B0CB2 /* UIKitExample */ = { isa = PBXNativeTarget; buildConfigurationList = 728FD67C2D649EC9006B0CB2 /* Build configuration list for PBXNativeTarget "UIKitExample" */; buildPhases = ( 728FD6642D649EC8006B0CB2 /* Sources */, 728FD6652D649EC8006B0CB2 /* Frameworks */, 728FD6662D649EC8006B0CB2 /* Resources */, 72FF7BAF2D65B94F00483C43 /* Run SwiftLint */, ); buildRules = ( ); dependencies = ( ); fileSystemSynchronizedGroups = ( 728FD6692D649EC8006B0CB2 /* UIKitExample */, ); name = UIKitExample; packageProductDependencies = ( 72FF7CDA2D6602E300483C43 /* Spices */, ); productName = UIKitExample; productReference = 728FD6682D649EC8006B0CB2 /* UIKitExample.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 725C9D262D5E26F100C79FDC /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1620; LastUpgradeCheck = 1620; TargetAttributes = { 725C9D2D2D5E26F100C79FDC = { CreatedOnToolsVersion = 16.2; }; 728FD6672D649EC8006B0CB2 = { CreatedOnToolsVersion = 16.2; }; }; }; buildConfigurationList = 725C9D292D5E26F100C79FDC /* Build configuration list for PBXProject "Example" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = 725C9D252D5E26F100C79FDC; minimizedProjectReferenceProxies = 1; packageReferences = ( 72FF7CD72D6602D600483C43 /* XCLocalSwiftPackageReference "../../spices" */, ); preferredProjectObjectVersion = 77; productRefGroup = 725C9D2F2D5E26F100C79FDC /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 725C9D2D2D5E26F100C79FDC /* SwiftUIExample */, 728FD6672D649EC8006B0CB2 /* UIKitExample */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 725C9D2C2D5E26F100C79FDC /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 728FD6662D649EC8006B0CB2 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ 72FF7BAF2D65B94F00483C43 /* Run SwiftLint */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( ); inputPaths = ( ); name = "Run SwiftLint"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\nif which swiftlint >/dev/null; then\n swiftlint --config ../.swiftlint.yml\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; }; 72FF7BB02D65B97C00483C43 /* Run SwiftLint */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( ); inputPaths = ( ); name = "Run SwiftLint"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\nif which swiftlint >/dev/null; then\n swiftlint --config ../.swiftlint.yml\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 725C9D2A2D5E26F100C79FDC /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 728FD6642D649EC8006B0CB2 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ 725C9D3A2D5E26F200C79FDC /* 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; IPHONEOS_DEPLOYMENT_TARGET = 17.6; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 6.0; }; name = Debug; }; 725C9D3B2D5E26F200C79FDC /* 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; IPHONEOS_DEPLOYMENT_TARGET = 17.6; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_VERSION = 6.0; VALIDATE_PRODUCT = YES; }; name = Release; }; 725C9D3D2D5E26F200C79FDC /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"SwiftUIExample/Preview Content\""; DEVELOPMENT_TEAM = 8NQFWJHC63; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dk.shape.Spices.SwiftUIExample; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Debug; }; 725C9D3E2D5E26F200C79FDC /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"SwiftUIExample/Preview Content\""; DEVELOPMENT_TEAM = 8NQFWJHC63; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dk.shape.Spices.SwiftUIExample; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Release; }; 728FD6792D649EC9006B0CB2 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 8NQFWJHC63; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = UIKitExample/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dk.shape.Spices.UIKitExample; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Debug; }; 728FD67A2D649EC9006B0CB2 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 8NQFWJHC63; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = UIKitExample/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dk.shape.Spices.UIKitExample; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 725C9D292D5E26F100C79FDC /* Build configuration list for PBXProject "Example" */ = { isa = XCConfigurationList; buildConfigurations = ( 725C9D3A2D5E26F200C79FDC /* Debug */, 725C9D3B2D5E26F200C79FDC /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 725C9D3C2D5E26F200C79FDC /* Build configuration list for PBXNativeTarget "SwiftUIExample" */ = { isa = XCConfigurationList; buildConfigurations = ( 725C9D3D2D5E26F200C79FDC /* Debug */, 725C9D3E2D5E26F200C79FDC /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 728FD67C2D649EC9006B0CB2 /* Build configuration list for PBXNativeTarget "UIKitExample" */ = { isa = XCConfigurationList; buildConfigurations = ( 728FD6792D649EC9006B0CB2 /* Debug */, 728FD67A2D649EC9006B0CB2 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ 72FF7CD72D6602D600483C43 /* XCLocalSwiftPackageReference "../../spices" */ = { isa = XCLocalSwiftPackageReference; relativePath = ../../spices; }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ 72FF7CD82D6602DE00483C43 /* Spices */ = { isa = XCSwiftPackageProductDependency; package = 72FF7CD72D6602D600483C43 /* XCLocalSwiftPackageReference "../../spices" */; productName = Spices; }; 72FF7CDA2D6602E300483C43 /* Spices */ = { isa = XCSwiftPackageProductDependency; package = 72FF7CD72D6602D600483C43 /* XCLocalSwiftPackageReference "../../spices" */; productName = Spices; }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 725C9D262D5E26F100C79FDC /* Project object */; } ================================================ FILE: Examples/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: Examples/Example.xcodeproj/xcshareddata/xcschemes/SwiftUIExample.xcscheme ================================================ ================================================ FILE: Examples/SwiftUIExample/AppSpiceStore.swift ================================================ import Foundation import Spices import SwiftUI enum ServiceEnvironment: String, CaseIterable { case production case staging } final class AppSpiceStore: SpiceStore { @Spice(requiresRestart: true) var environment: ServiceEnvironment = .production @Spice(name: "API URL") var apiURL = "http://example.com" @Spice(presentation: .inline) var debugging = DebuggingSpiceStore() @Spice var featureFlags = FeatureFlagsSpiceStore() @Spice(presentation: .push) var helloWorld = VStack { Image(systemName: "globe") .imageScale(.large) .foregroundStyle(.tint) Text("Hello, world!") } .padding() @Spice var version = LabeledContent("Version", value: "1.0 (1)") } final class DebuggingSpiceStore: SpiceStore { @Spice var enableLogging = false @Spice var clearCache = { try await Task.sleep(for: .seconds(1)) URLCache.shared.removeAllCachedResponses() } fileprivate init() {} } final class FeatureFlagsSpiceStore: SpiceStore { @Spice var notifications = false @Spice var fastRefreshWidgets = false fileprivate init() {} } ================================================ FILE: Examples/SwiftUIExample/Assets.xcassets/AccentColor.colorset/Contents.json ================================================ { "colors" : [ { "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/SwiftUIExample/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "AppIconLight.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "AppIconDark.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "tinted" } ], "filename" : "AppIconTinted.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/SwiftUIExample/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/SwiftUIExample/ContentView.swift ================================================ import Spices import SwiftUI struct ContentView: View { @EnvironmentObject private var spiceStore: AppSpiceStore @State private var isShowingEditorPopover = false var body: some View { NavigationStack { Form { Section { Text( "This is an example app showcasing the Spices framework." + "\n\n" + "The following illustrates how spices can be observed using SwiftUI." ) .foregroundStyle(.secondary) } Section { LabeledContent("Environment") { Text(String(describing: spiceStore.environment)) } LabeledContent("API URL") { Text(spiceStore.apiURL) } } Section { LabeledContent("Enable Logging") { Text(spiceStore.debugging.enableLogging ? "Yes" : "No") } } header: { Text("Debugging") } Section { LabeledContent("Notifications") { Text(spiceStore.featureFlags.notifications ? "Yes" : "No") } LabeledContent("Fast Refresh Widgets") { Text(spiceStore.featureFlags.fastRefreshWidgets ? "Yes" : "No") } } header: { Text("Feature Flags") } footer: { Text("Shake to edit spices.") .frame(maxWidth: .infinity, alignment: .center) .padding(.top, 30) } } .navigationTitle("Example") .toolbar { #if os(visionOS) ToolbarItem(placement: .topBarTrailing) { Button("Edit spices") { isShowingEditorPopover = true } .popover(isPresented: $isShowingEditorPopover) { SpiceEditor(editing: spiceStore) } } #endif } } #if DEBUG .presentSpiceEditorOnShake(editing: spiceStore) #endif } } #Preview { ContentView() .environmentObject(AppSpiceStore()) } ================================================ FILE: Examples/SwiftUIExample/ExampleApp.swift ================================================ import SwiftUI @main struct ExampleApp: App { var body: some Scene { WindowGroup { ContentView() } .environmentObject(AppSpiceStore()) } } ================================================ FILE: Examples/SwiftUIExample/Preview Content/Preview Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/UIKitExample/AppDelegate.swift ================================================ import UIKit @main final class AppDelegate: UIResponder, UIApplicationDelegate { func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { true } } ================================================ FILE: Examples/UIKitExample/AppSpiceStore.swift ================================================ import Foundation import Spices import SwiftUI enum ServiceEnvironment: String, CaseIterable { case production case staging } final class AppSpiceStore: SpiceStore { @MainActor static let shared = AppSpiceStore() @Spice(requiresRestart: true) var environment: ServiceEnvironment = .production @Spice(name: "API URL") var apiURL = "http://example.com" @Spice(presentation: .inline) var debugging = DebuggingSpiceStore() @Spice var featureFlags = FeatureFlagsSpiceStore() @Spice(presentation: .push) var helloWorld = VStack { Image(systemName: "globe") .imageScale(.large) .foregroundStyle(.tint) Text("Hello, world!") } .padding() @Spice var version = LabeledContent("Version", value: "1.0 (1)") private init() {} } final class DebuggingSpiceStore: SpiceStore { @Spice var enableLogging = false @Spice var clearCache = { try await Task.sleep(for: .seconds(1)) URLCache.shared.removeAllCachedResponses() } fileprivate init() {} } final class FeatureFlagsSpiceStore: SpiceStore { @Spice var notifications = false @Spice var fastRefreshWidgets = false fileprivate init() {} } ================================================ FILE: Examples/UIKitExample/Assets.xcassets/AccentColor.colorset/Contents.json ================================================ { "colors" : [ { "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/UIKitExample/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "AppIconLight.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "AppIconDark.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "tinted" } ], "filename" : "AppIconTinted.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/UIKitExample/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/UIKitExample/Base.lproj/LaunchScreen.storyboard ================================================ ================================================ FILE: Examples/UIKitExample/ContentViewController.swift ================================================ import Combine import Spices import UIKit final class ContentViewController: UIViewController { private enum Section { case intro case environment case debugging case featureFlags } fileprivate enum Item: Hashable { struct TextParameters: Hashable { let text: String } struct TitleValueParameters: Hashable { let title: String let value: String } case text(TextParameters) case titleValue(TitleValueParameters) var cellStyle: UITableViewCell.CellStyle { switch self { case .text: .default case .titleValue: .value1 } } static func text(_ text: String) -> Self { .text(.init(text: text)) } static func titleValue(title: String, value: String) -> Self { .titleValue(.init(title: title, value: value)) } } private final class DataSource: UITableViewDiffableDataSource { override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { guard let section = sectionIdentifier(for: section) else { return nil } switch section { case .featureFlags: return "Feature Flags" case .debugging: return "Debugging" case .intro, .environment: return nil } } } private let spiceStore = AppSpiceStore.shared private var diffableDataSource: DataSource? private var cancellables: Set = [] private let tableView: UITableView = { let this = UITableView(frame: .zero, style: .insetGrouped) this.translatesAutoresizingMaskIntoConstraints = false return this }() private lazy var editSpicesItem = UIBarButtonItem(title: "Edit spices", style: .plain, target: self, action: #selector(editSpicesButtonPressed)) override func loadView() { view = tableView } override func viewDidLoad() { super.viewDidLoad() title = "Example" tableView.delegate = self setupDataSource() updateSnapshot() observeSpices() updateNavigationItem() } } private extension ContentViewController { private func setupDataSource() { diffableDataSource = DataSource(tableView: tableView) { tableView, indexPath, item in let cell = tableView.dequeueReusableCell(ofStyle: item.cellStyle, indexPath: indexPath) cell.populate(with: item) return cell } tableView.dataSource = diffableDataSource } private func updateSnapshot() { let introItems: [Item] = [ .text( "This is an example app showcasing the Spices framework." + "\n\n" + "The following illustrates how spices can be observed using Combine from UIKit." ) ] let environmentItems: [Item] = [ .titleValue( title: "Environment", value: String(describing: spiceStore.environment) ), .titleValue( title: "API URL", value: spiceStore.apiURL ) ] let debuggingItems: [Item] = [ .titleValue( title: "Enable Logging", value: spiceStore.debugging.enableLogging ? "Yes" : "No" ) ] let featureFlagsItems: [Item] = [ .titleValue( title: "Notifications", value: spiceStore.featureFlags.notifications ? "Yes" : "No" ), .titleValue( title: "Fast Refresh Widgets", value: spiceStore.featureFlags.fastRefreshWidgets ? "Yes" : "No" ) ] var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.intro, .environment, .debugging, .featureFlags]) snapshot.appendItems(introItems, toSection: .intro) snapshot.appendItems(environmentItems, toSection: .environment) snapshot.appendItems(debuggingItems, toSection: .debugging) snapshot.appendItems(featureFlagsItems, toSection: .featureFlags) diffableDataSource?.apply(snapshot, animatingDifferences: false) } private func observeSpices() { Publishers.CombineLatest4( spiceStore.$environment, spiceStore.debugging.$enableLogging, spiceStore.$apiURL, spiceStore.featureFlags.$notifications ) .combineLatest(spiceStore.featureFlags.$fastRefreshWidgets) .sink { [weak self] _, _ in self?.updateSnapshot() } .store(in: &cancellables) } private func updateNavigationItem() { #if os(visionOS) navigationItem.rightBarButtonItem = editSpicesItem #endif } @objc private func editSpicesButtonPressed() { let editor = SpiceEditorViewController(editing: spiceStore) editor.modalPresentationStyle = .popover editor.popoverPresentationController?.sourceItem = editSpicesItem present(editor, animated: true) } } extension ContentViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { guard let section = diffableDataSource?.sectionIdentifier(for: section) else { return nil } guard case .featureFlags = section else { return nil } let label = UILabel() label.numberOfLines = 0 label.textColor = .secondaryLabel label.font = .preferredFont(forTextStyle: .footnote) label.textAlignment = .center label.text = "\n\nShake to edit spices." return label } } private extension UITableViewCell { func populate(with item: ContentViewController.Item) { selectionStyle = .none switch item { case .text(let parameters): var configuration = defaultContentConfiguration() configuration.text = parameters.text configuration.textProperties.color = .secondaryLabel contentConfiguration = configuration case .titleValue(let parameters): var configuration = defaultContentConfiguration() configuration.text = parameters.title configuration.secondaryText = parameters.value contentConfiguration = configuration } } } private extension UITableView { func dequeueReusableCell(ofStyle style: UITableViewCell.CellStyle, indexPath: IndexPath) -> UITableViewCell { let reuseIdentifier = "UITableViewCell[\(style.rawValue)]" guard let cell = dequeueReusableCell(withIdentifier: reuseIdentifier) else { return UITableViewCell(style: style, reuseIdentifier: reuseIdentifier) } return cell } } ================================================ FILE: Examples/UIKitExample/Info.plist ================================================ UIApplicationSceneManifest UIApplicationSupportsMultipleScenes UISceneConfigurations UIWindowSceneSessionRoleApplication UISceneConfigurationName Default Configuration UISceneDelegateClassName $(PRODUCT_MODULE_NAME).SceneDelegate ================================================ FILE: Examples/UIKitExample/SceneDelegate.swift ================================================ import Spices import UIKit final class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? func scene( _ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions ) { guard let windowScene = scene as? UIWindowScene else { fatalError("Expected scene of type \(UIWindowScene.self) but got \(type(of: scene))") } #if DEBUG window = SpiceEditorWindow(windowScene: windowScene, editing: AppSpiceStore.shared) #else window = UIWindow(windowScene: windowScene) #endif window?.rootViewController = makeRootViewController() window?.makeKeyAndVisible() } } private extension SceneDelegate { private func makeRootViewController() -> UINavigationController { let viewController = ContentViewController() let navigationController = UINavigationController(rootViewController: viewController) navigationController.navigationBar.prefersLargeTitles = true return navigationController } } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 Shape ApS 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. ================================================ FILE: Package.swift ================================================ // swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "Spices", platforms: [.iOS(.v15), .visionOS(.v1)], products: [ .library(name: "Spices", targets: ["Spices"]) ], targets: [ .target(name: "Spices"), .testTarget(name: "SpicesTests", dependencies: [ "Spices" ]) ] ) ================================================ FILE: README.md ================================================ # 🫙🌶 Spices

Spices makes it straightforward to create in-app debug menus by generating native UI from Swift.

Logo for Spices. A wooden shelf holds four glass jars filled with different spices. Above the shelf is a circular sign with the word 'Spices' and a red crossed-out bug. [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fshapehq%2Fspices%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/shapehq/spices) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fshapehq%2Fspices%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/shapehq/spices)\ [![Build](https://github.com/shapehq/shpspices/actions/workflows/build.yml/badge.svg)](https://github.com/shapehq/shpspices/actions/workflows/build.yml) [![Build Example Project](https://github.com/shapehq/shpspices/actions/workflows/build_example_project.yml/badge.svg)](https://github.com/shapehq/shpspices/actions/workflows/build_example_project.yml) [![SwiftLint](https://github.com/shapehq/shpspices/actions/workflows/swiftlint.yml/badge.svg)](https://github.com/shapehq/shpspices/actions/workflows/swiftlint.yml)\ [![Run Tests](https://github.com/shapehq/spices/actions/workflows/test.yml/badge.svg)](https://github.com/shapehq/spices/actions/workflows/test.yml) [![Build Documentation](https://github.com/shapehq/spices/actions/workflows/build_documentation.yml/badge.svg)](https://github.com/shapehq/spices/actions/workflows/build_documentation.yml) [![CodeQL](https://github.com/shapehq/spices/actions/workflows/codeql.yml/badge.svg)](https://github.com/shapehq/spices/actions/workflows/codeql.yml)
- [👋 Introduction](#-introduction) - [🚀 Getting Started](#-getting-started) - [Step 1: Add the Spices Swift Package](#step-1-add-the-spices-swift-package) - [Step 2: Create an In-App Debug Menu](#step-2-create-an-in-app-debug-menu) - [Step 3: Present the In-App Debug Menu](#step-3-present-the-in-app-debug-menu) - [Step 4: Observing Values](#step-4-observing-values) - [🧪 Example Projects](#-example-projects) - [📖 Documentation](#-documentation) - [Toggles](#toggles) - [Pickers](#pickers) - [Buttons](#buttons) - [Text Fields](#text-fields) - [Group Settings Using Nested Spice Stores](#group-settings-using-nested-spice-stores) - [Inject Your Own Views](#inject-your-own-views) - [Require Restart](#require-restart) - [Display Custom Name](#display-custom-name) - [Specify Editor Title](#specify-editor-title) - [Store Values in Custom UserDefaults](#store-values-in-custom-userdefaults) - [Store Values Under Custom Key](#store-values-under-custom-key) - [Using with @AppStorage](#using-with-appstorage) - [🤔 Why "Spices"?](#-why-spices) ## 👋 Introduction Spices generates native in-app debug menus from Swift code using the `@Spice` property wrapper and `SpiceStore` protocol and stores settings in [UserDefaults](https://developer.apple.com/documentation/foundation/userdefaults). We built Spices at [Shape](https://shape.dk) (becoming [Framna](https://framna.com)) to provide a frictionless API for quickly creating these menus. Common use cases include environment switching, resetting state, and enabling features during development. ## 🚀 Getting Started This section details the steps needed to add an in-app debug menu using Spices. ### Step 1: Add the Spices Swift Package Add Spices to your Xcode project or Swift package. ```swift let package = Package( dependencies: [ .package(url: "git@github.com:shapehq/spices.git", from: "4.0.0") ] ) ``` ### Step 2: Create an In-App Debug Menu Spices uses [reflection](https://en.wikipedia.org/wiki/Reflective_programming) to generate UI from the properties of a type conforming to the `SpiceStore` protocol > [!IMPORTANT] > Reflection is a technique that should be used with care. We use it in Spices, a tool meant purely for debugging, in order to make it frictionless to add a debug menu. The following shows an example conformance to the SpiceDispenser protocol. You may copy this into your project to get started. ```swift enum ServiceEnvironment: String, CaseIterable { case production case staging } class AppSpiceStore: SpiceStore { @Spice(requiresRestart: true) var environment: ServiceEnvironment = .production @Spice var enableLogging = false @Spice var clearCache = { try await Task.sleep(for: .seconds(1)) URLCache.shared.removeAllCachedResponses() } @Spice var featureFlags = FeatureFlagsSpiceStore() } class FeatureFlagsSpiceStore: SpiceStore { @Spice var notifications = false @Spice var fastRefreshWidgets = false } ``` Based on the above code, Spices will generate an in-app debug menu like the one shown below. ### Step 3: Present the In-App Debug Menu The app must be configured to display the spice editor. The approach depends on whether your app is using a SwiftUI or UIKit lifecycle. > [!WARNING] > The in-app debug menu may contain sensitive information. Ensure it's only accessible in debug and beta builds by excluding the menu's presentation code from release builds using conditional compilation (e.g., `#if DEBUG`). The examples in this section demonstrate this technique. #### SwiftUI Lifecycle Use the `presentSpiceEditorOnShake(_:)` view modifier to show the editor when the device is shaken. ```swift struct ContentView: View { @StateObject var spiceStore = AppSpiceStore() var body: some View { VStack { Image(systemName: "globe") .imageScale(.large) .foregroundStyle(.tint) Text("Hello, world!") } .padding() #if DEBUG .presentSpiceEditorOnShake(editing: spiceStore) #endif } } ``` Alternatively, manually initialize and display an instance of `SpiceEditor`. ```swift struct ContentView: View { @StateObject var spiceStore = AppSpiceStore() @State var isSpiceEditorPresented = false var body: some View { Button { isSpiceEditorPresented = true } label: { Text("Present Spice Editor") } .sheet(isPresented: $isSpiceEditorPresented) { SpiceEditor(editing: spiceStore) } } } ``` #### UIKit Lifecycle Use the an instance of `SpiceEditorWindow` to show the editor when the device is shaken. ```swift class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? func scene( _ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions ) { let windowScene = scene as! UIWindowScene #if DEBUG window = SpiceEditorWindow(windowScene: windowScene, editing: AppSpiceStore.shared) #else window = UIWindow(windowScene: windowScene) #endif window?.rootViewController = ViewController() window?.makeKeyAndVisible() } } ``` Alternatively, initialize an instance of `SpiceEditorViewController` and present it. ```swift let viewController = SpiceEditorViewController(editing: AppSpiceStore.shared) present(spicesViewController, animated: true) ``` ### Step 4: Observing Values The currently selected value can be referenced through a spice store: ```swift AppSpiceStore.environment ``` #### SwiftUI Lifecycle Spice stores conforming to the `SpiceStore` protocol also conform to [ObservableObject](https://developer.apple.com/documentation/combine/observableobject), and as such, can be observed from SwiftUI using [StateObject](https://developer.apple.com/documentation/swiftui/stateobject), [ObservedObject](https://developer.apple.com/documentation/swiftui/observedobject), or [EnvironmentObject](https://developer.apple.com/documentation/swiftui/environmentobject). ```swift class AppSpiceStore: SpiceStore { @Spice var enableLogging = false } struct ContentView: View { @StateObject var spiceStore = AppSpiceStore() var body: some View { Text("Is logging enabled: " + (spiceStore.enableLogging ? "👍" : "👎")) } } ``` #### UIKit Lifecycle Properties using the `@Spice` property wrapper exposes a publisher that can be used to observe changes to the value using [Combine](https://developer.apple.com/documentation/combine). ```swift class ContentViewController: UIViewController { private let spiceStore = AppSpiceStore.shared private var cancellables: Set = [] override func viewDidLoad() { super.viewDidLoad() spiceStore.$enableLogging .sink { isEnabled in print("Is logging enabled: " + (isEnabled ? "👍" : "👎")) } .store(in: &cancellables) } } ``` ## 🧪 Example Projects The example projects in the [Examples](/Examples) folder shows how Spices can be used to add an in-app debug menu to iOS apps with SwiftUI and UIKit lifecycles. ## 📖 Documentation

The documentation is available on Swift Package Index.


The following sections document select APIs and use cases. ### Toggles Toggles are created for boolean variables in a spice store. ```swift @Spice var enableLogging = false ``` ### Pickers Pickers are created for types conforming to both [RawRepresentable](https://developer.apple.com/documentation/swift/rawrepresentable) and [CaseIterable](https://developer.apple.com/documentation/swift/caseiterable). This is typically enums. ```swift enum ServiceEnvironment: String, CaseIterable { case production case staging } class AppSpiceStore: SpiceStore { @Spice var environment: ServiceEnvironment = .production } ``` Conforming the type to `SpicesTitleProvider` lets you override the displayed name for each case. ```swift enum ServiceEnvironment: String, CaseIterable, SpicesTitleProvider { case production case staging var spicesTitle: String { switch self { case .production: "🚀 Production" case .staging: "🧪 Staging" } } } ``` ### Buttons Closures with no arguments are treated as buttons. ```swift @Spice var clearCache = { URLCache.shared.removeAllCachedResponses() } ``` Providing an asynchronous closure causes a loading indicator to be displayed for the duration of the operation. ```swift @Spice var clearCache = { try await Task.sleep(for: .seconds(1)) URLCache.shared.removeAllCachedResponses() } ``` An error message is automatically shown if the closure throws an error. ### Text Fields Text fields are created for string variables in a spice store. ```swift @Spice var url = "http://example.com" ``` ### Group Settings Using Nested Spice Stores Spice stores can be nested to create a hierarchical user interface. ```swift class AppSpiceStore: SpiceStore { @Spice var featureFlags = FeatureFlagsSpiceStore() } class FeatureFlagsSpiceStore: SpiceStore { @Spice var notifications = false @Spice var fastRefreshWidgets = false } ``` By default, a nested spice store is presented as a new screen in the navigation stack. This behavior is equivalent to: ```swift @Spice(presentation: .push) var featureFlags = FeatureFlagsSpiceStore() ``` A nested spice store can also be presented as a modal instead of being pushed onto the navigation stack: ```swift @Spice(presentation: .modal) var featureFlags = FeatureFlagsSpiceStore() ``` Alternatively, it can be displayed as an inlined section within the settings list: ```swift @Spice(presentation: .inline) var featureFlags = FeatureFlagsSpiceStore() ``` When inlining a nested spice store, a header and footer can be provided for better context: ```swift @Spice( presentation: .push, header: "Features", footer: "Test features that are yet to be released." ) var featureFlags = FeatureFlagsSpiceStore() ``` ### Inject Your Own Views You can embed your own views into Spices, for example, to display static information. The `@Spice` property wrapper allows you to define custom views within Spices settings. These views can be inlined by default or presented using different styles. By default, views are inlined within the settings list: ```swift @Spice var version = LabeledContent("Version", value: "1.0 (1)") ``` You can change the presentation style using the presentation argument. The `.push` presentation pushes the view onto the navigation stack. ```swift @Spice(presentation: .push) var helloWorld = VStack { Image(systemName: "globe") .imageScale(.large) .foregroundStyle(.tint) Text("Hello, world!") } .padding() ``` The `.modal` presentation presents the view modally on top of Spices. ```swift @Spice(presentation: .modal) var helloWorld = // ... ``` ### Require Restart Setting `requiresRestart` to true will cause the app to be shut down after changing the value. Use this only when necessary, as users do not expect a restart. ```swift @Spice(requiresRestart: true) var environment: ServiceEnvironment = .production ``` ### Display Custom Name By default, the editor displays a formatted version of the property name. You can override this by manually specifying a custom name. ```swift @Spice(name: "Debug Logging") var enableLogging = false ``` ### Specify Editor Title By default the editor will be displayed with the title "Debug Menu". This can be customized as follows. **SwiftUI Lifecycle** The `presentSpiceEditorOnShake(editing:title:)` view modifier takes a title as follows. ```swift .presentSpiceEditorOnShake(editing: spiceStore, title: "Config") ``` The title can also be specified when manually creating and presenting an instance of `SpiceEditor`. ```swift SpiceEditor(editing: spiceStore, title: "Config") ``` **UIKit Lifecycle** The ``SpiceEditorWindow`` can be initialized with a title as follows. ```swift SpiceEditorWindow(windowScene: windowScene, editing: AppSpiceStore.shared, title: "Config") ``` The title can also be specified when manually creating and presenting an instance of `SpiceEditorViewController`. ```swift let viewController = SpiceEditorViewController(editing: AppSpiceStore.shared, title: "Config") ``` ### Store Values in Custom UserDefaults By default, values are stored in [UserDefaults.standard](https://developer.apple.com/documentation/foundation/userdefaults/1416603-standard). To use a different [UserDefaults](https://developer.apple.com/documentation/foundation/userdefaults) instance, such as for sharing data with an app group, implement the `userDefaults` property of `SpiceStore`. ```swift class AppSpiceStore: SpiceStore { let userDefaults = UserDefaults(suiteName: "group.dk.shape.example") } ``` ### Store Values Under Custom Key Values are stored in [UserDefaults](https://developer.apple.com/documentation/foundation/userdefaults) using a key derived from the property name, optionally prefixed with the names of nested spice stores. You can override this by specifying a custom key. ```swift @Spice(key: "env") var environment: ServiceEnvironment = .production ``` ### Using with @AppStorage Values are stored in [UserDefaults](https://developer.apple.com/documentation/foundation/userdefaults) and can be used with [@AppStorage](https://developer.apple.com/documentation/swiftui/appstorage) for seamless integration in SwiftUI. ```swift struct ExampleView: View { @AppStorage("enableLogging") var enableLogging = false var body: some View { Toggle(isOn: $enableLogging) { Text("Enable Logging") } } } ``` ## 🤔 Why "Spices"? The name "Spices" evolved from our original repository, "ConfigVars", which we used internally at [Shape](https://shape.dk) (becoming [Framna](https://framna.com)) for several years. That early version didn’t use reflection, but when we experimented with a new implementation that did, we jokingly called it "spicing it up." The idea stuck, and we realized developers could also use the package to "spice up" their own projects, adding extra debugging "spices" as needed. For the first few years, the project was called "Config Vars", but we never really loved that name. It felt too generic. When we decided to open-source the package, we considered reverting to the original name or using other generic alternatives like "configs," "variables," "tweaks," or "configuration variables." However, these terms are so widely used and have so many different meanings that we worried about causing naming conflicts in developers' codebases. Ultimately, we stuck with "Spices" because it’s unique, memorable, and less likely to clash with existing concepts. ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Reporting a Vulnerability Please contact us at security@shape.dk if you find a security vulnerability in the software. Do note that we do not offer a bug bounty program. ================================================ FILE: Sources/Spices/Internal/MenuItems/AsyncButtonMenuItem.swift ================================================ import Foundation struct AsyncButtonMenuItem: MenuItem { let id = UUID().uuidString let name: Name let requiresRestart: Bool let storage: AnyStorage } ================================================ FILE: Sources/Spices/Internal/MenuItems/ButtonMenuItem.swift ================================================ import Foundation struct ButtonMenuItem: MenuItem { let id = UUID().uuidString let name: Name let requiresRestart: Bool let storage: AnyStorage } ================================================ FILE: Sources/Spices/Internal/MenuItems/ChildSpiceStoreMenuItem.swift ================================================ import Foundation struct ChildSpiceStoreMenuItem: MenuItem { enum PresentationStyle { case modal case push case inline(header: String?, footer: String?) } let id = UUID().uuidString let name: Name let presentationStyle: PresentationStyle let spiceStore: any SpiceStore } ================================================ FILE: Sources/Spices/Internal/MenuItems/MenuItem.swift ================================================ protocol MenuItem { var id: String { get } } ================================================ FILE: Sources/Spices/Internal/MenuItems/MenuItemProvider.swift ================================================ protocol MenuItemProvider { var menuItem: any MenuItem { get } } ================================================ FILE: Sources/Spices/Internal/MenuItems/PickerMenuItem.swift ================================================ import Combine import Foundation final class PickerMenuItem: MenuItem, ObservableObject { struct Option: Hashable, Identifiable { let id: String let title: String fileprivate let write: () -> Void static var unsupported: Self { Self(id: "__spices_unsupported", title: "") {} } static func == (lhs: PickerMenuItem.Option, rhs: PickerMenuItem.Option) -> Bool { lhs.id == rhs.id && lhs.title == rhs.title } func hash(into hasher: inout Hasher) { hasher.combine(id) hasher.combine(title) } } let id = UUID().uuidString let name: Name let requiresRestart: Bool @Published var options: [Option] @Published var selection: Option { didSet { selection.write() } } private var cancellables: Set = [] init( name: Name, storage: AnyStorage, requiresRestart: Bool ) { self.name = name self.requiresRestart = requiresRestart let (options, selection) = Self.options(from: storage) self.options = options self.selection = selection observeValue(in: storage) } } private extension PickerMenuItem { private static func options( from storage: AnyStorage ) -> (options: [Option], selection: Option) { var options = Value.allCases.map { Option($0, writingTo: storage) } let selection: Option if let selectedOption = options.first(where: { $0.id == storage.value.optionId }) { selection = selectedOption } else { selection = .unsupported options.insert(.unsupported, at: 0) } return (options, selection) } private func observeValue(in storage: AnyStorage) { storage.publisher.sink { [weak self] newValue in guard newValue.optionId != self?.selection.id else { return } guard let newSelection = self?.options.first(where: { $0.id == newValue.optionId }) else { return } self?.selection = newSelection } .store(in: &cancellables) } } private extension PickerMenuItem.Option { init(_ value: Value, writingTo storage: AnyStorage) { let title = if let spicesTitleProvider = value as? SpicesTitleProvider { spicesTitleProvider.spicesTitle } else { String(describing: value).camelCaseToNaturalText() } self.init(id: value.optionId, title: title) { storage.value = value } } } private extension CaseIterable where Self: RawRepresentable { var optionId: String { String(describing: self) } } ================================================ FILE: Sources/Spices/Internal/MenuItems/TextFieldMenuItem.swift ================================================ import Combine import Foundation final class TextFieldMenuItem: MenuItem, ObservableObject { let id = UUID().uuidString let name: Name let requiresRestart: Bool @Published var value: String { didSet { if value != storage.value { storage.value = value } } } private let storage: AnyStorage private var cancellables: Set = [] init(name: Name, requiresRestart: Bool, storage: AnyStorage) { self.name = name self.requiresRestart = requiresRestart self.storage = storage self.value = storage.value storage.publisher.sink { [weak self] newValue in if newValue != self?.value { self?.value = newValue } } .store(in: &cancellables) } } ================================================ FILE: Sources/Spices/Internal/MenuItems/ToggleMenuItem.swift ================================================ import Combine import Foundation final class ToggleMenuItem: MenuItem, ObservableObject { let id = UUID().uuidString let name: Name let requiresRestart: Bool @Published var value: Bool { didSet { if value != storage.value { storage.value = value } } } private let storage: AnyStorage private var cancellables: Set = [] init(name: Name, requiresRestart: Bool, storage: AnyStorage) { self.name = name self.requiresRestart = requiresRestart self.storage = storage self.value = storage.value storage.publisher.sink { [weak self] newValue in if newValue != self?.value { self?.value = newValue } } .store(in: &cancellables) } } ================================================ FILE: Sources/Spices/Internal/MenuItems/ViewMenuItem.swift ================================================ import Foundation import SwiftUI final class ViewMenuItem: MenuItem { enum PresentationStyle { case modal case push case inline } let id = UUID().uuidString let name: Name let presentationStyle: PresentationStyle let content: AnyView init(name: Name, presentationStyle: PresentationStyle, content: AnyView) { self.name = name self.presentationStyle = presentationStyle self.content = content } } ================================================ FILE: Sources/Spices/Internal/Name.swift ================================================ final class Name { var rawValue: String { get { explicitValue ?? _value ?? "" } set { _value = newValue } } private let explicitValue: String? private var _value: String? init(_ value: String? = nil) { explicitValue = value } } ================================================ FILE: Sources/Spices/Internal/Preparable.swift ================================================ protocol Preparable { func prepare(propertyName: String, ownedBy spiceStore: any SpiceStore) } ================================================ FILE: Sources/Spices/Internal/Storage/AnyStorage.swift ================================================ import Combine final class AnyStorage: ObservableObject { private(set) var isPrepared = false let publisher: AnyPublisher var value: Value { get { read() } set { objectWillChange.send() write(newValue) } } private let read: () -> Value private let write: (Value) -> Void private let prepare: (String, any SpiceStore) -> Void init(_ storage: S) where S.Value == Value { read = { storage.value } write = { storage.value = $0 } prepare = { storage.prepare(propertyName: $0, ownedBy: $1) } publisher = storage.publisher } } extension AnyStorage: Preparable { func prepare(propertyName: String, ownedBy spiceStore: any SpiceStore) { prepare(propertyName, spiceStore) isPrepared = true } } ================================================ FILE: Sources/Spices/Internal/Storage/Storage.swift ================================================ import Combine protocol Storage: AnyObject, Preparable { associatedtype Value var value: Value { get set } var publisher: AnyPublisher { get } } ================================================ FILE: Sources/Spices/Internal/Storage/ThrowingStorage.swift ================================================ import Combine final class ThrowingStorage: Storage { var value: Value { get { initialValue } // swiftlint:disable:next unused_setter_value set { fatalError(setterMessage) } } let publisher: AnyPublisher private let initialValue: Value private let setterMessage: String private let passthroughSubject = PassthroughSubject() init(default initialValue: Value, setterMessage: String) { self.initialValue = initialValue self.setterMessage = setterMessage self.publisher = passthroughSubject.eraseToAnyPublisher() } func prepare(propertyName: String, ownedBy spiceStore: any SpiceStore) {} } ================================================ FILE: Sources/Spices/Internal/Storage/UserDefaultsStorage.swift ================================================ import Combine import Foundation final class UserDefaultsStorage: Storage { var value: Value { get { backingValue } set { if !isValuesEqual(newValue, backingValue) { write?(newValue) spiceStoreOrThrow.publishObjectWillChange() backingValue = newValue subject.send(newValue) } } } var publisher: AnyPublisher { subject.eraseToAnyPublisher() } private let subject: CurrentValueSubject private let preferredKey: String? private var read: (() -> Value)? private var write: ((Value) -> Void)? private var key: String { preferredKey ?? spiceStoreOrThrow.key(fromPropertyNamed: propertyNameOrThrow) } private var userDefaults: UserDefaults { spiceStoreOrThrow.userDefaults } private var propertyName: String? private var propertyNameOrThrow: String { guard let propertyName else { fatalError("\(type(of: self)) cannot be used without a spice name") } return propertyName } private weak var spiceStore: (any SpiceStore)? private var spiceStoreOrThrow: any SpiceStore { guard let spiceStore else { fatalError("\(type(of: self)) cannot be used without a reference to a spice store") } return spiceStore } private var backingValue: Value private let isValuesEqual: (Value, Value) -> Bool private var cancellables: Set = [] init(default value: Value, key: String?) where Value: Equatable { backingValue = value preferredKey = key subject = CurrentValueSubject(value) isValuesEqual = { $0 == $1 } read = { [weak self] in guard let self else { return value } return self.userDefaults.object(forKey: self.key) as? Value ?? value } write = { [weak self] newValue in guard let self else { return } self.userDefaults.setValue(newValue, forKey: self.key) } } init(default value: Value, key: String?) where Value: RawRepresentable, Value.RawValue: Equatable { backingValue = value preferredKey = key subject = CurrentValueSubject(value) isValuesEqual = { $0.rawValue == $1.rawValue } read = { [weak self] in guard let self, let rawValue = self.userDefaults.object(forKey: self.key) as? Value.RawValue else { return value } return Value(rawValue: rawValue) ?? value } write = { [weak self] newValue in guard let self else { return } self.userDefaults.setValue(newValue.rawValue, forKey: self.key) } } } private extension UserDefaultsStorage { private func observeUserDefaults() { NotificationCenter.default .publisher(for: UserDefaults.didChangeNotification) .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self, let read = self.read else { return } let value = read() guard !self.isValuesEqual(value, backingValue) else { return } self.spiceStoreOrThrow.publishObjectWillChange() self.backingValue = value self.subject.send(value) } .store(in: &cancellables) } } extension UserDefaultsStorage: Preparable { func prepare(propertyName: String, ownedBy spiceStore: any SpiceStore) { self.propertyName = propertyName self.spiceStore = spiceStore if let read { let value = read() if !isValuesEqual(value, backingValue) { backingValue = value subject.send(value) } } observeUserDefaults() } } ================================================ FILE: Sources/Spices/Internal/String+Helpers.swift ================================================ import Foundation extension String { func removing(prefix: String) -> Self { guard hasPrefix(prefix) else { return self } return String(dropFirst(prefix.count)) } /// Split into camel case words, preserving initialisms like URL and HTTP. func camelCaseToNaturalText() -> String { var pieces = [String]() var currentPiece = "" for (idx, character) in zip(indices, self) { if idx == startIndex { currentPiece += character.uppercased() } else if character.isUppercase { // Small check: recognize runs of multiple uppercase letters and // consider them part of the same word until the start of the next word. let previousIndex = index(before: idx) let nextIndex = index(after: idx) let previous = self[previousIndex] let next = nextIndex < endIndex ? self[nextIndex] : nil if previous.isUppercase && next?.isLowercase == true { // Previous word was an initialism, and this uppercase letter starts a new word // containing the next character. pieces.append(currentPiece) currentPiece = String(character) } else if previous.isUppercase { // Continue the initialism. currentPiece.append(character) } else { // Previous word was not an initialism, start a new word. pieces.append(currentPiece) currentPiece = character.uppercased() } } else { currentPiece.append(character) } } // Commit the last bit of the phrase. if !currentPiece.isEmpty { pieces.append(currentPiece) } return pieces.joined(separator: " ") } } ================================================ FILE: Sources/Spices/Internal/UIApplication+Helpers.swift ================================================ #if canImport(UIKit) import UIKit extension UIApplication { func shp_restart() { let topViewController = shp_activeWindow?.rootViewController?.shp_topViewController let alertController = UIAlertController( title: "Restart Required", message: "Shutting down app...", preferredStyle: .alert ) topViewController?.present(alertController, animated: true) DispatchQueue.main.asyncAfter(deadline: .now() + 2) { self.performSelector( onMainThread: NSSelectorFromString("su" + "sp" + "end"), with: nil, waitUntilDone: true ) Thread.sleep(forTimeInterval: 0.2) exit(0) } } // swiftlint:disable:next identifier_name var shp_activeWindow: UIWindow? { guard let preferredScene = shp_preferredScene else { return nil } return preferredScene.windows.first { $0.isKeyWindow } ?? preferredScene.windows.first } } private extension UIApplication { // swiftlint:disable:next identifier_name private var shp_preferredScene: UIWindowScene? { let windowScenes = connectedScenes.compactMap { $0 as? UIWindowScene } if let scene = windowScenes.first(where: { $0.activationState == .foregroundActive }) { return scene } else { return windowScenes.first } } } #endif ================================================ FILE: Sources/Spices/Internal/UIViewController+Helpers.swift ================================================ #if canImport(UIKit) import UIKit extension UIViewController { // swiftlint:disable:next identifier_name var shp_topViewController: UIViewController { var topViewController = self while let presentedViewController = topViewController.presentedViewController { topViewController = presentedViewController } return topViewController } } #endif ================================================ FILE: Sources/Spices/Internal/View+RestartOnChange.swift ================================================ #if canImport(UIKit) import SwiftUI public extension View { func restartApp(_ isActive: Binding) -> some View { modifier(RestartOnChangeViewModifier(isActive: isActive)) } } private struct RestartOnChangeViewModifier: ViewModifier { @Binding var isActive: Bool func body(content: Content) -> some View { content.onChange(of: isActive) { newValue in if newValue { UIApplication.shared.shp_restart() } } } } #endif ================================================ FILE: Sources/Spices/Internal/Views/AsyncButtonMenuItemView.swift ================================================ import SwiftUI struct AsyncButtonMenuItemView: View { let menuItem: AsyncButtonMenuItem @EnvironmentObject private var userInteraction: UserInteraction @State private var isLoading = false @State private var isErrorPresented = false @State private var error: Error? var body: some View { Button { Task { defer { userInteraction.isEnabled = true isLoading = false } do { userInteraction.isEnabled = false isLoading = true try await menuItem.storage.value() if menuItem.requiresRestart { UIApplication.shared.shp_restart() } } catch { self.error = error isErrorPresented = true } } } label: { HStack { Text(menuItem.name.rawValue) Spacer() ProgressView() .progressViewStyle(.circular) .opacity(isLoading ? 1 : 0) } } .errorAlert(isPresented: $isErrorPresented, showing: error) } } ================================================ FILE: Sources/Spices/Internal/Views/ButtonMenuItemView.swift ================================================ import SwiftUI struct ButtonMenuItemView: View { let menuItem: ButtonMenuItem @State private var isErrorPresented = false @State private var error: Error? var body: some View { Button { do { try menuItem.storage.value() if menuItem.requiresRestart { UIApplication.shared.shp_restart() } } catch { self.error = error self.isErrorPresented = true } } label: { Text(menuItem.name.rawValue) } .errorAlert(isPresented: $isErrorPresented, showing: error) } } ================================================ FILE: Sources/Spices/Internal/Views/ChildSpiceStoreMenuItemView.swift ================================================ import SwiftUI struct ChildSpiceStoreMenuItemView: View { let menuItem: ChildSpiceStoreMenuItem let dismiss: () -> Void var body: some View { switch menuItem.presentationStyle { case .modal: ModalPresentationView(menuItem: menuItem) case .push: NavigationLink { ChildMenuItemListView(menuItem: menuItem, dismiss: dismiss) } label: { Text(menuItem.name.rawValue) } case let .inline(header, footer): Section { MenuItemListContent( menuItems: menuItem.spiceStore.menuItems, dismiss: dismiss ) } header: { if let header { Text(header) } } footer: { if let footer { Text(footer) } } } } } private extension ChildSpiceStoreMenuItemView { struct ChildMenuItemListView: View { let menuItem: ChildSpiceStoreMenuItem let dismiss: () -> Void var body: some View { MenuItemListView( items: menuItem.spiceStore.menuItems, title: menuItem.name.rawValue, dismiss: dismiss ) .navigationBarTitleDisplayMode(.inline) } } } private extension ChildSpiceStoreMenuItemView { struct ModalPresentationView: View { let menuItem: ChildSpiceStoreMenuItem @State private var isModalPresented = false var body: some View { Button { isModalPresented = true } label: { Text(menuItem.name.rawValue) } .sheet(isPresented: $isModalPresented) { NavigationView { ChildMenuItemListView(menuItem: menuItem) { isModalPresented = false } } } } } } ================================================ FILE: Sources/Spices/Internal/Views/MenuItemListContent.swift ================================================ import SwiftUI struct MenuItemListContent: View { let menuItems: [MenuItem] let dismiss: () -> Void var body: some View { ForEach(menuItems, id: \.id) { menuItem in MenuItemView(menuItem: menuItem, dismiss: dismiss) } } } ================================================ FILE: Sources/Spices/Internal/Views/MenuItemListView.swift ================================================ import SwiftUI struct MenuItemListView: View { @EnvironmentObject private var userInteraction: UserInteraction private let title: String private let menuItems: [MenuItem] private let dismiss: () -> Void init(items menuItems: [MenuItem], title: String, dismiss: @escaping () -> Void) { self.title = title self.menuItems = menuItems self.dismiss = dismiss } var body: some View { Form { MenuItemListContent(menuItems: menuItems, dismiss: dismiss) } .disabled(!userInteraction.isEnabled) .navigationTitle(title) .toolbar { ToolbarItem(placement: .primaryAction) { Button { dismiss() } label: { Text("Done").fontWeight(.bold) } } } } } ================================================ FILE: Sources/Spices/Internal/Views/MenuItemView.swift ================================================ import SwiftUI struct MenuItemView: View { let menuItem: any MenuItem let dismiss: () -> Void var body: some View { if let menuItem = menuItem as? ToggleMenuItem { ToggleMenuItemView(menuItem: menuItem) } else if let menuItem = menuItem as? PickerMenuItem { PickerMenuItemView(menuItem: menuItem) } else if let menuItem = menuItem as? TextFieldMenuItem { TextFieldMenuItemView(menuItem: menuItem) } else if let menuItem = menuItem as? ButtonMenuItem { ButtonMenuItemView(menuItem: menuItem) } else if let menuItem = menuItem as? AsyncButtonMenuItem { AsyncButtonMenuItemView(menuItem: menuItem) } else if let menuItem = menuItem as? ChildSpiceStoreMenuItem { ChildSpiceStoreMenuItemView(menuItem: menuItem, dismiss: dismiss) } else if let menuItem = menuItem as? ViewMenuItem { ViewMenuItemView(menuItem: menuItem) } else { fatalError("Unknown menu item of type \(type(of: menuItem))") } } } ================================================ FILE: Sources/Spices/Internal/Views/PickerMenuItemView.swift ================================================ import SwiftUI struct PickerMenuItemView: View { @ObservedObject private var menuItem: PickerMenuItem @State private var selection: PickerMenuItem.Option @State private var restartApp = false init(menuItem: PickerMenuItem) { self.menuItem = menuItem self.selection = menuItem.selection } var body: some View { Picker(selection: $selection) { ForEach(menuItem.options) { option in Text(option.title) .tag(option) } } label: { Text(menuItem.name.rawValue) } .onChange(of: selection) { newValue in if newValue != menuItem.selection { menuItem.selection = newValue restartApp = menuItem.requiresRestart } } .onChange(of: menuItem.selection) { newValue in if newValue != selection { selection = newValue } } .restartApp($restartApp) } } ================================================ FILE: Sources/Spices/Internal/Views/TextFieldMenuItemView.swift ================================================ import SwiftUI struct TextFieldMenuItemView: View { @ObservedObject var menuItem: TextFieldMenuItem @State private var editingValue: String = "" @State private var restartApp = false var body: some View { GeometryReader { proxy in HStack { Text(menuItem.name.rawValue) Spacer() TextField("Value", text: $editingValue) .multilineTextAlignment(.trailing) .submitLabel(.done) .frame(minWidth: proxy.size.width / 2) } .frame(height: proxy.size.height, alignment: .center) } .onSubmit { menuItem.value = editingValue restartApp = menuItem.requiresRestart } .restartApp($restartApp) .onAppear { editingValue = menuItem.value } } } ================================================ FILE: Sources/Spices/Internal/Views/ToggleMenuItemView.swift ================================================ import SwiftUI struct ToggleMenuItemView: View { @ObservedObject var menuItem: ToggleMenuItem @State private var isOn = false @State private var restartApp = false var body: some View { Toggle(isOn: $isOn) { Text(menuItem.name.rawValue) } .onChange(of: isOn) { newValue in if newValue != menuItem.value { menuItem.value = newValue restartApp = menuItem.requiresRestart } } .onChange(of: menuItem.value) { newValue in if newValue != isOn { isOn = newValue } } .restartApp($restartApp) .onAppear { isOn = menuItem.value } } } ================================================ FILE: Sources/Spices/Internal/Views/UserInteraction.swift ================================================ import SwiftUI final class UserInteraction: ObservableObject { @Published var isEnabled = true } ================================================ FILE: Sources/Spices/Internal/Views/View+ConfigureSheetPresentation.swift ================================================ import SwiftUI @available(iOS 16, *) private struct ConfigureSheetPresentationViewModifier: ViewModifier { func body(content: Content) -> some View { content.presentationDetents([.medium, .large]) } } extension View { @ViewBuilder func configureSheetPresentation() -> some View { if #available(iOS 16, *) { modifier(ConfigureSheetPresentationViewModifier()) } else { self } } } ================================================ FILE: Sources/Spices/Internal/Views/View+ErrorAlert.swift ================================================ import SwiftUI extension View { func errorAlert(isPresented: Binding, showing error: Error? = nil) -> some View { modifier(ErrorAlertViewModifier(isPresented: isPresented, error: error)) } } private struct ErrorAlertViewModifier: ViewModifier { @Binding var isPresented: Bool let error: Error? func body(content: Content) -> some View { content .alert("Error Occurred", isPresented: $isPresented) { Button("OK", role: .cancel) { isPresented = false } } message: { if let error { Text(error.localizedDescription) } else { Text("An unknown error occurred during the operation.") } } } } ================================================ FILE: Sources/Spices/Internal/Views/ViewMenuItemView.swift ================================================ import SwiftUI struct ViewMenuItemView: View { let menuItem: ViewMenuItem var body: some View { switch menuItem.presentationStyle { case .modal: ModalPresentationView(menuItem.name.rawValue, content: menuItem.content) case .push: NavigationLink(menuItem.name.rawValue, destination: menuItem.content) case .inline: menuItem.content } } } private extension ViewMenuItemView { struct ModalPresentationView: View { private let title: String private let content: Content @State private var isModalPresented = false init(_ title: String, content: Content) { self.title = title self.content = content } var body: some View { Button { isModalPresented = true } label: { Text(title) } .sheet(isPresented: $isModalPresented) { content } } } } ================================================ FILE: Sources/Spices/PresentationStyle.swift ================================================ /// A type that represents different styles for presenting a view within the in-app debug menu. public protocol PresentationStyle {} /// A presentation style that displays the view modally on top of the Spices settings. public struct ModalPresentationStyle: PresentationStyle { fileprivate init() {} } /// A presentation style that pushes the view onto the navigation stack. public struct PushPresentationStyle: PresentationStyle { fileprivate init() {} } /// A presentation style that inlines the view within the settings list. public struct InlinePresentationStyle: PresentationStyle { fileprivate init() {} } public extension PresentationStyle { /// The modal presentation style, which presents the view modally. /// /// ## Example Usage /// /// Use the presentation style to present a nested spice store modally. /// /// ```swift /// @Spice(presentation: .modal) var featureFlags = FeatureFlagsSpiceStore() /// /// The presentation style can also be used to present a view modally. /// /// ```swift /// @Spice(presentation: .modal) var helloWorld = VStack { /// Image(systemName: "globe") /// .imageScale(.large) /// .foregroundStyle(.tint) /// Text("Hello, world!") /// } /// .padding() /// ``` static var modal: ModalPresentationStyle { ModalPresentationStyle() } /// The push presentation style, which pushes the view onto the navigation stack. /// /// ## Example Usage /// /// Use the presentation style to push a nested spice store onto the navigation stack. /// /// ```swift /// @Spice(presentation: .push) var featureFlags = FeatureFlagsSpiceStore() /// /// The presentation style can also be used to push a view onto the navigation stack. /// /// ```swift /// @Spice(presentation: .push) var helloWorld = VStack { /// Image(systemName: "globe") /// .imageScale(.large) /// .foregroundStyle(.tint) /// Text("Hello, world!") /// } /// .padding() /// ``` static var push: PushPresentationStyle { PushPresentationStyle() } /// The inline presentation style, which inlines the view within the settings list. /// /// ## Example Usage /// /// Use the presentation style to inline a nested spice store within the current list. /// /// ```swift /// @Spice(presentation: .inline) var featureFlags = FeatureFlagsSpiceStore() /// /// The presentation style can also be used to inline a view. /// /// ```swift /// @Spice var version = LabeledContent("Version", value: "1.0 (1)") /// ``` static var inline: InlinePresentationStyle { InlinePresentationStyle() } } ================================================ FILE: Sources/Spices/Spice.swift ================================================ // swiftlint:disable file_length import Combine import Foundation import SwiftUI /// A property wrapper for exposing settings in a generated in-app debug menus. /// /// Spices generates native in-app debug menus from Swift code using the `Spice` property wrapper and ``SpiceStore`` protocol and stores settings in `UserDefaults`. /// /// ## Example Usage /// /// A spice story can be created as follows. /// /// ```swift /// class AppSpiceStore: SpiceStore { /// @Spice(requiresRestart: true) var environment: ServiceEnvironment = .production /// @Spice var enableLogging = false /// @Spice var clearCache = { /// try await Task.sleep(for: .seconds(1)) /// URLCache.shared.removeAllCachedResponses() /// } /// } /// ``` /// /// A spice store conforms to `ObservableObject` and can be observed in SwiftUI. /// /// ```swift /// struct ContentView: View { /// @EnvironmentObject private var spiceStore: AppSpiceStore /// /// var body: some View { /// Text("Is logging enabled: " + (spiceStore.enableLogging ? "👍" : "👎")) /// } /// } /// ``` /// /// Values can also be observed in UIKit. /// /// ```swift /// final class ContentViewController: UIViewController { /// private let spiceStore = AppSpiceStore.shared /// private var cancellables: Set = [] /// /// override func viewDidLoad() { /// super.viewDidLoad() /// spiceStore.$enableLogging /// .sink { isEnabled in /// print("Is logging enabled: " + (isEnabled ? "👍" : "👎")) /// } /// .store(in: &cancellables) /// } /// } /// ``` @propertyWrapper public struct Spice { /// Type alias for a synchronous button handler. public typealias ButtonHandler = () throws -> Void /// Type alias for an asynchronous button handler. public typealias AsyncButtonHandler = () async throws -> Void /// The wrapped value is unavailable. /// /// Getting or setting the value will throw a fatal error. @available(*, unavailable, message: "@Spice can only be applied to classes") public var wrappedValue: Value { get { fatalError("Getting the wrapped value from a @Spice property wrapper is not supported") } // swiftlint:disable:next unused_setter_value set { fatalError("Setting the wrapped value on a @Spice property wrapper is not supported") } } /// A publisher that emits the current value of the setting whenever it changes. /// /// Use the publisher to observe changes in UIKit and SwiftUI. /// /// ```swift /// spiceStore.$myValue.sink { newValue in /// // ... /// } /// ``` public var projectedValue: AnyPublisher { storage.publisher } let name: Name let menuItem: any MenuItem private let storage: AnyStorage /// Initializes a `Spice` property wrapper for a boolean setting. /// /// **Example Usage:** /// /// ```swift /// @Spice var enableLogging = false /// ``` /// - Parameters: /// - wrappedValue: The initial value of the boolean setting. /// - key: The key used to store the setting in UserDefaults. Defaults to a key generated from the property name. /// - name: The display name of the setting. Defaults to a formatted version of the property name. /// - requiresRestart: Set to `true` to restart the application when changing the value. Defaults to `false`. public init( wrappedValue: Value, key: String? = nil, name: String? = nil, requiresRestart: Bool = false ) where Value == Bool { self.name = Name(name) self.storage = AnyStorage(UserDefaultsStorage(default: wrappedValue, key: key)) self.menuItem = ToggleMenuItem( name: self.name, requiresRestart: requiresRestart, storage: self.storage ) } /// Initializes a `Spice` property wrapper for a string setting. /// /// **Example Usage:** /// /// ```swift /// @Spice(name: "API URL") var apiURL = "http://example.com" /// ``` /// - Parameters: /// - wrappedValue: The initial value of the string setting. /// - key: The key used to store the setting in UserDefaults. Defaults to a key generated from the property name. /// - name: The display name of the setting. Defaults to a formatted version of the property name. /// - requiresRestart: Set to `true` to restart the application when changing the value. Defaults to `false`. public init( wrappedValue: Value, key: String? = nil, name: String? = nil, requiresRestart: Bool = false ) where Value == String { self.name = Name(name) self.storage = AnyStorage(UserDefaultsStorage(default: wrappedValue, key: key)) self.menuItem = TextFieldMenuItem( name: self.name, requiresRestart: requiresRestart, storage: self.storage ) } /// Initializes a `Spice` property wrapper for an enum setting. /// /// **Example Usage:** /// /// ```swift /// enum ServiceEnvironment: String, CaseIterable { /// case production /// case staging /// } /// /// @Spice(requiresRestart: true) var environment: ServiceEnvironment = .production /// ``` /// - Parameters: /// - wrappedValue: The initial value of the enum setting. /// - key: The key used to store the setting in UserDefaults. Defaults to a key generated from the property name. /// - name: The display name of the setting. Defaults to a formatted version of the property name. /// - requiresRestart: Set to `true` to restart the application when changing the value. Defaults to `false`. public init( wrappedValue: Value, key: String? = nil, name: String? = nil, requiresRestart: Bool = false ) where Value: RawRepresentable & CaseIterable, Value.RawValue: Equatable { self.name = Name(name) self.storage = AnyStorage(UserDefaultsStorage(default: wrappedValue, key: key)) self.menuItem = PickerMenuItem( name: self.name, storage: self.storage, requiresRestart: requiresRestart ) } /// Initializes a `Spice` property wrapper for a synchronous button action. /// /// **Example Usage:** /// /// ```swift /// @Spice var clearCache = { /// URLCache.shared.removeAllCachedResponses() /// } /// ``` /// - Parameters: /// - wrappedValue: The closure representing the button's action. /// - name: The display name of the setting. Defaults to a formatted version of the property name. /// - requiresRestart: Set to `true` to restart the application when changing the value. Defaults to `false`. public init( wrappedValue: Value, name: String? = nil, requiresRestart: Bool = false ) where Value == ButtonHandler { self.name = Name(name) self.storage = AnyStorage(ThrowingStorage( default: wrappedValue, setterMessage: "Cannot set closure of a button spice." )) self.menuItem = ButtonMenuItem( name: self.name, requiresRestart: requiresRestart, storage: self.storage ) } /// Initializes a `Spice` property wrapper for a asynchronous button action. /// /// **Example Usage:** /// /// ```swift /// @Spice var clearCache = { /// try await Task.sleep(for: .seconds(1)) /// URLCache.shared.removeAllCachedResponses() /// } /// ``` /// - Parameters: /// - wrappedValue: The closure representing the button's action. /// - name: The display name of the setting. Defaults to a formatted version of the property name. /// - requiresRestart: Set to `true` to restart the application when changing the value. Defaults to `false`. public init( wrappedValue: Value, name: String? = nil, requiresRestart: Bool = false ) where Value == AsyncButtonHandler { self.name = Name(name) self.storage = AnyStorage(ThrowingStorage( default: wrappedValue, setterMessage: "Cannot set closure of button spice." )) self.menuItem = AsyncButtonMenuItem( name: self.name, requiresRestart: requiresRestart, storage: self.storage ) } /// Initializes a `Spice` property wrapper for a child spice store. /// /// **Example Usage:** /// /// ```swift /// @Spice var featureFlags = FeatureFlagsSpiceStore() /// ``` /// /// - Parameters: /// - wrappedValue: The spice store to create hierarchial navigation to. /// - name: The display name of the spice store. Defaults to a formatted version of the property name. public init(wrappedValue: Value, name: String? = nil) where Value: SpiceStore { self.name = Name(name) self.storage = AnyStorage(ThrowingStorage( default: wrappedValue, setterMessage: "Cannot assign new reference to nested spice store." )) self.menuItem = ChildSpiceStoreMenuItem( name: self.name, presentationStyle: .push, spiceStore: wrappedValue ) } /// Initializes a `Spice` property wrapper for a child spice store. /// - Parameters: /// - wrappedValue: The spice store to create hierarchial navigation to. /// - name: The display name of the spice store. Defaults to a formatted version of the property name. /// - presentation: Presentation style of the spice store. public init( wrappedValue: Value, name: String? = nil, presentation: PushPresentationStyle ) where Value: SpiceStore { self.name = Name(name) self.storage = AnyStorage(ThrowingStorage( default: wrappedValue, setterMessage: "Cannot assign new reference to nested spice store." )) self.menuItem = ChildSpiceStoreMenuItem( name: self.name, presentationStyle: .push, spiceStore: wrappedValue ) } /// Initializes a `Spice` property wrapper for a child spice store. /// - Parameters: /// - wrappedValue: The spice store to create hierarchial navigation to. /// - name: The display name of the spice store. Defaults to a formatted version of the property name. /// - presentation: Presentation style of the spice store. /// - header: Title of the section header. /// - footer: Title of the section footer. public init( wrappedValue: Value, name: String? = nil, presentation: ModalPresentationStyle, header: String? = nil, footer: String? = nil ) where Value: SpiceStore { self.name = Name(name) self.storage = AnyStorage(ThrowingStorage( default: wrappedValue, setterMessage: "Cannot assign new reference to nested spice store." )) self.menuItem = ChildSpiceStoreMenuItem( name: self.name, presentationStyle: .modal, spiceStore: wrappedValue ) } /// Initializes a `Spice` property wrapper for a child spice store. /// - Parameters: /// - wrappedValue: The spice store to create hierarchial navigation to. /// - name: The display name of the spice store. Defaults to a formatted version of the property name. /// - presentation: Presentation style of the spice store. /// - header: Title of the section header. /// - footer: Title of the section footer. public init( wrappedValue: Value, name: String? = nil, presentation: InlinePresentationStyle, header: String? = nil, footer: String? = nil ) where Value: SpiceStore { self.name = Name(name) self.storage = AnyStorage(ThrowingStorage( default: wrappedValue, setterMessage: "Cannot assign new reference to nested spice store." )) self.menuItem = ChildSpiceStoreMenuItem( name: self.name, presentationStyle: .inline(header: header, footer: footer), spiceStore: wrappedValue ) } /// Initializes a `Spice` property wrapper for a custom view. /// /// **Example Usage:** /// /// ```swift /// @Spice var version = LabeledContent("Version", value: "1.0 (1)") /// ``` /// - Parameters: /// - wrappedValue: The custom view to embed. public init(wrappedValue: some View) where Value == AnyView { self.name = Name(nil) self.storage = AnyStorage(ThrowingStorage( default: AnyView(wrappedValue), setterMessage: "Cannot assign new reference to a custom view spice." )) self.menuItem = ViewMenuItem( name: self.name, presentationStyle: .inline, content: AnyView(wrappedValue) ) } /// Initializes a `Spice` property wrapper for a custom view. /// /// **Example Usage:** /// /// ```swift /// @Spice var version = LabeledContent("Version", value: "1.0 (1)") /// ``` /// - Parameters: /// - wrappedValue: The custom view to embed. /// - presentation: Presentation style of the custom view. public init(wrappedValue: some View, presentation: InlinePresentationStyle) where Value == AnyView { self.name = Name(nil) self.storage = AnyStorage(ThrowingStorage( default: AnyView(wrappedValue), setterMessage: "Cannot assign new reference to a custom view spice." )) self.menuItem = ViewMenuItem( name: self.name, presentationStyle: .inline, content: AnyView(wrappedValue) ) } /// Initializes a `Spice` property wrapper for a custom view presented modally. /// /// **Example Usage:** /// /// ```swift /// @Spice(presentation: .modal) var helloWorld = VStack { /// Image(systemName: "globe") /// .imageScale(.large) /// .foregroundStyle(.tint) /// Text("Hello, world!") /// } /// .padding() /// ``` /// - Parameters: /// - wrappedValue: The custom view to embed. /// - name: The display name of the spice store. Defaults to a formatted version of the property name. /// - presentation: Presentation style of the custom view. public init( wrappedValue: some View, name: String? = nil, presentation: ModalPresentationStyle ) where Value == AnyView { self.name = Name(name) self.storage = AnyStorage(ThrowingStorage( default: AnyView(wrappedValue), setterMessage: "Cannot assign new reference to a custom view spice." )) self.menuItem = ViewMenuItem( name: self.name, presentationStyle: .modal, content: AnyView(wrappedValue) ) } /// Initializes a `Spice` property wrapper for a custom view pushed onto the navigation stack. /// /// **Example Usage:** /// /// ```swift /// @Spice(presentation: .push) var helloWorld = VStack { /// Image(systemName: "globe") /// .imageScale(.large) /// .foregroundStyle(.tint) /// Text("Hello, world!") /// } /// .padding() /// ``` /// - Parameters: /// - wrappedValue: The custom view to embed. /// - name: The display name of the spice store. Defaults to a formatted version of the property name. /// - presentation: Presentation style of the custom view. public init( wrappedValue: some View, name: String? = nil, presentation: PushPresentationStyle ) where Value == AnyView { self.name = Name(name) self.storage = AnyStorage(ThrowingStorage( default: AnyView(wrappedValue), setterMessage: "Cannot assign new reference to a custom view spice." )) self.menuItem = ViewMenuItem( name: self.name, presentationStyle: .push, content: AnyView(wrappedValue) ) } /// A static subscript that provides access to the `Spice` property wrapper's value within a `SpiceStore`. /// /// This allows for reading and writing the value of the setting. static public subscript( _enclosingInstance instance: T, wrapped wrappedKeyPath: ReferenceWritableKeyPath, storage storageKeyPath: ReferenceWritableKeyPath ) -> Value { get { instance.prepareIfNeeded() return instance[keyPath: storageKeyPath].storage.value } set { instance.prepareIfNeeded() instance[keyPath: storageKeyPath].storage.value = newValue } } } extension Spice: Preparable { func prepare(propertyName: String, ownedBy spiceStore: any SpiceStore) { name.rawValue = propertyName.camelCaseToNaturalText() storage.prepare(propertyName: propertyName, ownedBy: spiceStore) prepareChildSpiceStoreIfNeeded(propertyName: propertyName, parent: spiceStore) } } private extension Spice { private func prepareChildSpiceStoreIfNeeded(propertyName: String, parent: any SpiceStore) { guard let childSpiceStore = storage.value as? any SpiceStore else { return } guard childSpiceStore.parent == nil else { fatalError("A child spice store can only be referenced from one parent.") } childSpiceStore.parent = parent childSpiceStore.propertyName = propertyName childSpiceStore.prepareIfNeeded() } } extension Spice: MenuItemProvider {} // swiftlint:enable file_length ================================================ FILE: Sources/Spices/SpiceEditor.swift ================================================ import SwiftUI /// A SwiftUI view that presents an in-app debug menu for editing settings managed by a ``SpiceStore``. /// /// `SpiceEditor` displays a list of menu items corresponding to the ``Spice`` properties defined in a ``SpiceStore``. /// /// ## Example Usage /// /// ```swift /// struct ContentView: View { /// @EnvironmentObject private var spiceStore: AppSpiceStore /// @State private var isSpiceEditorPresented = false /// /// var body: some View { /// VStack { /// Button("Open Debug Menu") { /// isSpiceEditorPresented = true /// } /// } /// .sheet(isPresented: $isSpiceEditorPresented) { /// SpiceEditor(editing: spiceStore) /// } /// } /// } /// ``` /// /// The `SpiceEditor` can also be presented when the device is shaken using the `presentSpiceEditorOnShake(_:)` view modifier. /// /// ```swift /// struct ContentView: View { /// @EnvironmentObject private var spiceStore: AppSpiceStore /// /// var body: some View { /// NavigationStack { /// // ... /// } /// #if DEBUG /// .presentSpiceEditorOnShake(editing: spiceStore) /// #endif /// } /// } /// ``` public struct SpiceEditor: View { private let title: String private let spiceStore: any SpiceStore @Environment(\.dismiss) private var dismiss /// Initializes a `SpiceEditor` with a ``SpiceStore``. /// /// - Parameter spiceStore: The ``SpiceStore`` containing the settings to be edited. public init(editing spiceStore: any SpiceStore) { self.spiceStore = spiceStore self.title = "Debug Menu" } /// Initializes a `SpiceEditor` with a ``SpiceStore``. /// /// - Parameter spiceStore: The ``SpiceStore`` containing the settings to be edited. /// - Parameter title: The title displayed in the navigation bar. public init(editing spiceStore: any SpiceStore, title: String) { self.spiceStore = spiceStore self.title = title } /// The content of the view. public var body: some View { NavigationView { MenuItemListView(items: spiceStore.menuItems, title: title) { dismiss() } } .configureSheetPresentation() .environmentObject(UserInteraction()) } } ================================================ FILE: Sources/Spices/SpiceEditorViewController.swift ================================================ #if canImport(UIKit) import SwiftUI import UIKit /// A UIKit view controller that hosts a ``SpiceEditor`` view for editing settings in a ``SpiceStore``. /// /// ## Example Usage /// /// ```swift /// let viewController = SpiceEditorViewController(editing: AppSpiceStore.shared) /// present(viewController, animated: true) /// ``` /// /// The `SpiceEditorViewController` can also be presented when the device is shaken using ``SpiceEditorWindow``. public final class SpiceEditorViewController: UIHostingController { /// Initializes a `SpiceEditorViewController` with a ``SpiceStore``. /// /// - Parameter spiceStore: The ``SpiceStore`` containing the settings to be edited. public init(editing spiceStore: any SpiceStore) { super.init(rootView: SpiceEditor(editing: spiceStore)) configureSheetPresentation() } /// Initializes a `SpiceEditorViewController` with a ``SpiceStore``. /// /// - Parameter spiceStore: The ``SpiceStore`` containing the settings to be edited. /// - Parameter title: The title displayed in the navigation bar. public init(editing spiceStore: any SpiceStore, title: String) { super.init(rootView: SpiceEditor(editing: spiceStore, title: title)) configureSheetPresentation() } @MainActor @preconcurrency dynamic required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } } private extension SpiceEditorViewController { private func configureSheetPresentation() { #if !os(visionOS) sheetPresentationController?.detents = [.medium(), .large()] #endif } } #endif ================================================ FILE: Sources/Spices/SpiceEditorWindow.swift ================================================ #if canImport(UIKit) import SwiftUI import UIKit /// A `UIWindow` subclass that facilitates the presentation of an in-app debug menu for editing settings managed by a ``SpiceStore``. /// /// `SpiceEditorWindow` provides a shake-to-show gesture for presenting the debug menu. When the device is shaken, it presents a ``SpiceEditorViewController`` /// modally. /// /// **Example Usage:** /// /// The following demonstrates how `SpiceEditorWindow` can be used in a scene delegate. /// /// - Important: The in-app debug menu may contain sensitive information. Ensure it's only accessible in debug and beta builds by excluding the menu's presentation code from release builds using conditional compilation (e.g., `#if DEBUG`). The examples in this section demonstrate this technique. /// /// ```swift /// #if DEBUG /// window = SpiceEditorWindow(windowScene: windowScene, editing: AppSpiceStore.shared) /// #else /// window = UIWindow(windowScene: windowScene) /// #endif /// ``` open class SpiceEditorWindow: UIWindow { private static weak var presentedSpicesEditorViewController: UIViewController? private let spiceStore: (any SpiceStore)? private let title: String? /// Initializes a `SpiceEditorWindow` with a `UIWindowScene`. /// /// This initializer does not associate a `SpiceStore` with the window, so the shake gesture will not present the debug menu. /// /// - Parameter windowScene: The scene to which the window belongs. override public init(windowScene: UIWindowScene) { self.spiceStore = nil self.title = nil super.init(windowScene: windowScene) } /// Initializes a `SpiceEditorWindow` with a `UIWindowScene` and a ``SpiceStore``. /// /// This initializer associates a `SpiceStore` with the window, enabling the shake gesture to present the debug menu for the provided store. /// /// - Parameters: /// - windowScene: The scene to which the window belongs. /// - spiceStore: The ``SpiceStore`` containing the settings to be edited. public init(windowScene: UIWindowScene, editing spiceStore: any SpiceStore) { self.spiceStore = spiceStore self.title = nil super.init(windowScene: windowScene) } /// Initializes a `SpiceEditorWindow` with a `UIWindowScene` and a ``SpiceStore``. /// /// This initializer associates a `SpiceStore` with the window, enabling the shake gesture to present the debug menu for the provided store. /// /// - Parameters: /// - windowScene: The scene to which the window belongs. /// - spiceStore: The ``SpiceStore`` containing the settings to be edited. /// - title: The title displayed in the navigation bar. public init(windowScene: UIWindowScene, editing spiceStore: any SpiceStore, title: String) { self.spiceStore = spiceStore self.title = title super.init(windowScene: windowScene) } /// Unavailable initializer. public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// Overrides the default motion handling to present the debug menu on a shake gesture. override open func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { if let spiceStore, motion == .motionShake { presentSpicesEditor(editing: spiceStore) } super.motionEnded(motion, with: event) } } private extension SpiceEditorWindow { private func presentSpicesEditor(editing spiceStore: any SpiceStore) { guard SpiceEditorWindow.presentedSpicesEditorViewController == nil else { return } let window = UIApplication.shared.shp_activeWindow let topViewController = window?.rootViewController?.shp_topViewController let viewController = if let title { SpiceEditorViewController(editing: spiceStore, title: title) } else { SpiceEditorViewController(editing: spiceStore) } topViewController?.present(viewController, animated: true) SpiceEditorWindow.presentedSpicesEditorViewController = viewController } } #endif ================================================ FILE: Sources/Spices/SpiceStore.swift ================================================ import Combine import Foundation import ObjectiveC nonisolated(unsafe) private var idKey: UInt8 = 0 nonisolated(unsafe) private var nameKey: UInt8 = 0 nonisolated(unsafe) private var propertyNameKey: UInt8 = 0 nonisolated(unsafe) private var isPreparedKey: UInt8 = 0 nonisolated(unsafe) private var parentKey: UInt8 = 0 /// A protocol for classes that manage a collection of settings exposed in an in-app debug menu. /// /// `SpiceStore` objects own and manage ``Spice`` property wrappers, providing a central point for accessing and observing settings. /// /// ## Example Usage /// /// ```swift /// class AppSpiceStore: SpiceStore { /// @Spice(requiresRestart: true) var environment: ServiceEnvironment = .production /// @Spice var enableLogging = false /// @Spice var clearCache = { /// try await Task.sleep(for: .seconds(1)) /// URLCache.shared.removeAllCachedResponses() /// } /// /// let featureFlags = FeatureFlagsSpiceStore() /// } /// ``` public protocol SpiceStore: AnyObject, ObservableObject { /// The `UserDefaults` instance used for persisting settings. /// /// You can use this property to share settings among apps, or when developing an app extension, to share preferences or other data between the extension and its containing app. /// /// The default implementation returns `UserDefaults.standard`. var userDefaults: UserDefaults { get } } public extension SpiceStore { var userDefaults: UserDefaults { .standard } } public extension SpiceStore { /// Ensures that the `SpiceStore` is prepared before accessing its projected values. /// /// This method checks whether the `SpiceStore` has already been prepared. If not, it marks it as prepared /// and invokes the `prepare()` method. /// /// You typically do not need to call this method manually, as preparation happens automatically. However, /// if ``Spice/projectedValue`` is accessed before the corresponding property has been read or written, /// you must explicitly call `prepareIfNeeded()` to avoid accessing an unprepared state. /// /// - Important: If the `SpiceStore` is not prepared, accessing a projected value will trigger an assertion failure. /// func prepareIfNeeded() { guard !isPrepared else { return } isPrepared = true prepare() } } extension SpiceStore { var id: String { if let value = objc_getAssociatedObject(self, &idKey) as? String { return value } else { let value = UUID().uuidString objc_setAssociatedObject(self, &idKey, value, .OBJC_ASSOCIATION_COPY_NONATOMIC) return value } } var propertyName: String { get { objc_getAssociatedObject(self, &propertyNameKey) as? String ?? "" } set { objc_setAssociatedObject(self, &propertyNameKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } } var parent: (any SpiceStore)? { get { objc_getAssociatedObject(self, &parentKey) as? any SpiceStore } set { objc_setAssociatedObject(self, &parentKey, newValue, .OBJC_ASSOCIATION_ASSIGN) } } private var isPrepared: Bool { get { (objc_getAssociatedObject(self, &isPreparedKey) as? Bool) ?? false } set { objc_setAssociatedObject(self, &isPreparedKey, newValue, .OBJC_ASSOCIATION_ASSIGN) } } private var path: [String] { if let parent { return parent.path + [propertyName] } else { return [] } } var menuItems: [MenuItem] { prepareIfNeeded() let mirror = Mirror(reflecting: self) return mirror.children.compactMap { _, value in guard let spice = value as? MenuItemProvider else { return nil } return spice.menuItem } } func key(fromPropertyNamed propertyName: String) -> String { (path + [propertyName]).joined(separator: ".") } func publishObjectWillChange() { let publisher = objectWillChange as? ObservableObjectPublisher publisher?.send() parent?.publishObjectWillChange() } private func prepare() { let mirror = Mirror(reflecting: self) for (name, value) in mirror.children { guard let name, let spice = value as? Preparable else { continue } let propertyName = name.removing(prefix: "_") spice.prepare(propertyName: propertyName, ownedBy: self) } } } ================================================ FILE: Sources/Spices/Spices.docc/Extensions/SpiceStore.md ================================================ # ``SpiceStore`` ## Topics ### Storage - ``SpiceStore/userDefaults`` ### Preparation - ``SpiceStore/prepareIfNeeded()`` ================================================ FILE: Sources/Spices/Spices.docc/Spices.md ================================================ # ``Spices`` Spices makes it straightforward to create in-app debug menus by generating native UI from Swift. ## Overview Spices generates native in-app debug menus from Swift code using the ``Spice`` property wrapper and ``SpiceStore`` protocol and stores settings in `UserDefaults`. We built Spices at [Shape](https://shape.dk) (becoming [Framna](https://framna.com)) to provide a frictionless API for quickly creating these menus. Common use cases include environment switching, resetting state, and enabling features during development. See [the README on GitHub](https://github.com/shapehq/spices) for reference documentation. ![iPhone screen recording showing an in-app debug menu.](example.gif) ## Topics ### Essentials - ``Spice`` - ``SpiceStore`` ### Present the In-App Debug Menu > Note: The in-app debug menu may contain sensitive information. Ensure it's only accessible in debug and beta builds by excluding the menu's presentation code from release builds using conditional compilation. - ``SpiceEditor`` - ``SpiceEditorViewController`` - ``SpiceEditorWindow`` - ``SwiftUICore/View/presentSpiceEditorOnShake(editing:)`` ### Customization - ``SpicesTitleProvider`` ================================================ FILE: Sources/Spices/SpicesTitleProvider.swift ================================================ /// A protocol for providing custom titles for enum values used with a ``Spice`` property wrapper. /// /// By default, Spices generates titles from the enum's cases. Conforming to `SpicesTitleProvider` allows you to override these default titles /// to provide more descriptive, user-friendly, or localized names in the debug menu. /// /// ## Example usage /// /// ```swift /// class AppSpiceStore: SpiceStore { /// @Spice var environment: ServiceEnvironment =.production /// } /// /// enum ServiceEnvironment: String, CaseIterable, SpicesTitleProvider { /// case production /// case staging /// /// var spicesTitle: String { /// switch self { /// case .production: /// "🚀 Production" /// case .staging: /// "🧪 Staging" /// } /// } /// } /// ``` public protocol SpicesTitleProvider { /// The title to be displayed in the in-app debug menu. var spicesTitle: String { get } } ================================================ FILE: Sources/Spices/View+SpiceEditor.swift ================================================ #if canImport(UIKit) import SwiftUI public extension View { /// Presents a ``SpiceEditor`` for the given ``SpiceStore`` when the device is shaken. /// /// ## Example Usage /// /// The following shows how the view modifier can be used to present the in-app debug menu when the device is shaken. /// /// The view modifier should typically be used at the root of your app's view hierarchy. /// /// - Important: The in-app debug menu may contain sensitive information. Ensure it's only accessible in debug and beta builds by excluding the menu's presentation code from release builds using conditional compilation (e.g., `#if DEBUG`). The examples in this section demonstrate this technique. /// /// ```swift /// struct ContentView: View { /// @EnvironmentObject private var spiceStore: AppSpiceStore /// /// var body: some View { /// NavigationStack { /// // ... /// } /// #if DEBUG /// .presentSpiceEditorOnShake(editing: spiceStore) /// #endif /// } /// } /// ``` /// /// - Parameter spiceStore: The ``SpiceStore`` containing the settings to be edited. /// - Returns: A modified view that presents the ``SpiceEditor`` on shake. @ViewBuilder func presentSpiceEditorOnShake(editing spiceStore: T) -> some View { modifier(PresentSpiceEditorOnShakeViewModifier { SpiceEditor(editing: spiceStore) }) } /// Presents a ``SpiceEditor`` for the given ``SpiceStore`` when the device is shaken. /// /// ## Example Usage /// /// The following shows how the view modifier can be used to present the in-app debug menu when the device is shaken. /// /// The view modifier should typically be used at the root of your app's view hierarchy. /// /// - Important: The in-app debug menu may contain sensitive information. Ensure it's only accessible in debug and beta builds by excluding the menu's presentation code from release builds using conditional compilation (e.g., `#if DEBUG`). The examples in this section demonstrate this technique. /// /// ```swift /// struct ContentView: View { /// @EnvironmentObject private var spiceStore: AppSpiceStore /// /// var body: some View { /// NavigationStack { /// // ... /// } /// #if DEBUG /// .presentSpiceEditorOnShake(editing: spiceStore) /// #endif /// } /// } /// ``` /// /// - Parameter spiceStore: The ``SpiceStore`` containing the settings to be edited. /// - Parameter title: The title displayed in the navigation bar. /// - Returns: A modified view that presents the ``SpiceEditor`` on shake. @ViewBuilder func presentSpiceEditorOnShake(editing spiceStore: T, title: String) -> some View { modifier(PresentSpiceEditorOnShakeViewModifier { SpiceEditor(editing: spiceStore, title: title) }) } } private struct PresentSpiceEditorOnShakeViewModifier: ViewModifier { private let editor: Editor init(@ViewBuilder content: () -> Editor) { self.editor = content() } func body(content: Content) -> some View { content .onReceive(NotificationCenter.default.publisher( for: UIWindow.presentSpiceEditorNotification )) { publisher in guard let window = publisher.object as? UIWindow, window.isKeyWindow else { return } guard PresentedSpiceEditorBox.viewController == nil else { return } let viewController = UIHostingController(rootView: editor) #if !os(visionOS) viewController.sheetPresentationController?.detents = [.medium(), .large()] #endif window.rootViewController?.shp_topViewController.present(viewController, animated: true) PresentedSpiceEditorBox.viewController = viewController } } } @MainActor private struct PresentedSpiceEditorBox { static weak var viewController: UIViewController? private init() {} } extension UIWindow { fileprivate static let presentSpiceEditorNotification = Notification.Name("presentSpiceEditorNotification") override open func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { if motion == .motionShake { NotificationCenter.default.post(name: Self.presentSpiceEditorNotification, object: self) } } } #endif ================================================ FILE: Tests/SpicesTests/AnyStorageTests.swift ================================================ import Combine import Foundation @testable import Spices import Testing @Suite final class AnyStorageTests { private var cancellables: Set = [] @Test func it_reads_value() async throws { let storage = MockStorage(default: "Hello world!") let sut = AnyStorage(storage) #expect(sut.value == "Hello world!") } @Test func it_writes_value() async throws { let storage = MockStorage(default: "Hello world!") let sut = AnyStorage(storage) sut.value = "Foo" #expect(storage.value == "Foo") } @Test func it_notifies_observer_of_changes() async throws { let storage = MockStorage(default: "Hello world!") let sut = AnyStorage(storage) var didReceiveChange = false sut.objectWillChange.sink { didReceiveChange = true } .store(in: &cancellables) sut.value = "Foo" #expect(didReceiveChange == true) } @Test func it_updates_prepared_state() async throws { let storage = MockStorage(default: "Hello world!") let sut = AnyStorage(storage) #expect(sut.isPrepared == false) sut.prepare(propertyName: "foo", ownedBy: MockSpiceStore()) #expect(sut.isPrepared == true) } } ================================================ FILE: Tests/SpicesTests/CamelCaseToNaturalTextTests.swift ================================================ import Foundation @testable import Spices import Testing @Suite struct CamelCaseToNaturalTextTests { @Test func it_handles_simple_cases() async throws { #expect("simpleName".camelCaseToNaturalText() == "Simple Name") #expect("useLocalURL".camelCaseToNaturalText() == "Use Local URL") #expect("environment".camelCaseToNaturalText() == "Environment") #expect("enableLogging".camelCaseToNaturalText() == "Enable Logging") #expect("clearCache".camelCaseToNaturalText() == "Clear Cache") #expect("featureFlags".camelCaseToNaturalText() == "Feature Flags") #expect("notifications".camelCaseToNaturalText() == "Notifications") #expect("fastRefreshWidgets".camelCaseToNaturalText() == "Fast Refresh Widgets") #expect("ignoreNextHTTPRequest".camelCaseToNaturalText() == "Ignore Next HTTP Request") // Suboptimal, but heuristics for these would be annoying. #expect("httpVersion".camelCaseToNaturalText() == "Http Version") #expect("apiURL".camelCaseToNaturalText() == "Api URL") } } ================================================ FILE: Tests/SpicesTests/Helpers/UserDefaults+Helpers.swift ================================================ import Foundation extension UserDefaults { func removeAll() { let domain = Bundle.main.bundleIdentifier! removePersistentDomain(forName: domain) } } ================================================ FILE: Tests/SpicesTests/Mocks/MockEnvironment.swift ================================================ enum MockEnvironment: String, CaseIterable { case production case staging } ================================================ FILE: Tests/SpicesTests/Mocks/MockSpiceStore.swift ================================================ import Spices final class MockSpiceStore: SpiceStore { nonisolated(unsafe) static var buttonClosureCalled = false nonisolated(unsafe) static var asynButtonClosureCalled = false @Spice var boolValue = false @Spice var textValue = "Hello" @Spice var enumValue: MockEnvironment = .production @Spice var buttonValue = { MockSpiceStore.buttonClosureCalled = true } @Spice var asyncButtonValue: () async throws -> Void = { MockSpiceStore.asynButtonClosureCalled = true } @Spice(name: "Hello World!") var namedValue = false } ================================================ FILE: Tests/SpicesTests/Mocks/MockStorage.swift ================================================ import Combine @testable import Spices final class MockStorage: Storage, Preparable { var publisher: AnyPublisher { subject.eraseToAnyPublisher() } var value: Value { get { subject.value } set { subject.send(newValue) } } private let subject: CurrentValueSubject init(default value: Value) { subject = CurrentValueSubject(value) } func prepare(propertyName: String, ownedBy spiceStore: any SpiceStore) {} } ================================================ FILE: Tests/SpicesTests/SpiceTests.swift ================================================ import Combine import Foundation @testable import Spices import Testing @MainActor @Suite(.serialized) final class SpiceTests { private var cancellables: Set = [] @Test func it_reads_default_bool_value() { let sut = MockSpiceStore() sut.userDefaults.removeAll() #expect(sut.boolValue == false) } @Test func it_stores_bool_value_in_user_defaults() { let sut = MockSpiceStore() sut.userDefaults.removeAll() #expect(sut.userDefaults.object(forKey: "boolValue") == nil) sut.boolValue = true #expect(sut.userDefaults.bool(forKey: "boolValue") == true) } @Test func it_reads_default_enum_Value() { let sut = MockSpiceStore() sut.userDefaults.removeAll() #expect(sut.enumValue == .production) } @Test func it_stores_enum_value_in_user_defaults() { let sut = MockSpiceStore() sut.userDefaults.removeAll() #expect(sut.userDefaults.object(forKey: "enumValue") == nil) sut.enumValue = .staging #expect(sut.userDefaults.string(forKey: "enumValue") == MockEnvironment.staging.rawValue) } @Test func it_stores_string_value_in_user_defaults() { let sut = MockSpiceStore() sut.userDefaults.removeAll() #expect(sut.userDefaults.object(forKey: "textValue") == nil) sut.textValue = "Test value" #expect(sut.userDefaults.string(forKey: "textValue") == "Test value") } @Test func it_stores_button_closure() throws { let sut = MockSpiceStore() sut.userDefaults.removeAll() MockSpiceStore.buttonClosureCalled = false try sut.buttonValue() #expect(MockSpiceStore.buttonClosureCalled == true) } @Test func it_stores_async_button_closure() async throws { let sut = MockSpiceStore() sut.userDefaults.removeAll() MockSpiceStore.asynButtonClosureCalled = false try await sut.asyncButtonValue() #expect(MockSpiceStore.asynButtonClosureCalled == true) } @Test func it_sink_receives_initial_value() async throws { var initialValue: MockEnvironment? let sut = MockSpiceStore() sut.userDefaults.removeAll() sut.prepareIfNeeded() sut.$enumValue.sink { newValue in initialValue = newValue } .store(in: &cancellables) #expect(initialValue == .production) } @Test func it_sink_receives_initial_value_if_it_has_been_changed() async throws { var initialValue: MockEnvironment? let sut = MockSpiceStore() sut.userDefaults.removeAll() sut.userDefaults.set(MockEnvironment.staging.rawValue, forKey: "enumValue") sut.prepareIfNeeded() sut.$enumValue.sink { newValue in initialValue = newValue } .store(in: &cancellables) #expect(initialValue == .staging) } @Test func it_publishes_values() async throws { var publishedValue: MockEnvironment? let sut = MockSpiceStore() sut.userDefaults.removeAll() sut.prepareIfNeeded() sut.$enumValue.sink { newValue in publishedValue = newValue } .store(in: &cancellables) #expect(sut.enumValue == .production) sut.enumValue = .staging #expect(publishedValue == .staging) } } ================================================ FILE: Tests/SpicesTests/UserDefaultsStorageTests.swift ================================================ import Combine import Foundation @testable import Spices import Testing @MainActor @Suite(.serialized) final class UserDefaultsStorageTests { private var cancellables: Set = [] @Test func it_returns_default_value() async throws { let spiceStore = MockSpiceStore() spiceStore.userDefaults.removeAll() let sut = UserDefaultsStorage(default: "default", key: nil) sut.prepare(propertyName: "foo", ownedBy: spiceStore) #expect(sut.value == "default") } @Test func it_stores_value_under_property_name() async throws { let spiceStore = MockSpiceStore() spiceStore.userDefaults.removeAll() let sut = UserDefaultsStorage(default: "default", key: nil) sut.prepare(propertyName: "foo", ownedBy: spiceStore) sut.value = "Hello world!" #expect(spiceStore.userDefaults.string(forKey: "foo") == "Hello world!") } @Test func it_stores_value_under_provided_key() async throws { let spiceStore = MockSpiceStore() spiceStore.userDefaults.removeAll() let sut = UserDefaultsStorage(default: "default", key: "bar") sut.prepare(propertyName: "foo", ownedBy: spiceStore) sut.value = "Hello world!" #expect(spiceStore.userDefaults.string(forKey: "bar") == "Hello world!") } @Test func it_stores_raw_representable_values() async throws { func makeStorage( default defaultValue: Value ) -> UserDefaultsStorage where Value.RawValue: Equatable { UserDefaultsStorage(default: defaultValue, key: nil) } let spiceStore = MockSpiceStore() spiceStore.userDefaults.removeAll() let sut = makeStorage(default: MockEnvironment.production) sut.prepare(propertyName: "foo", ownedBy: spiceStore) sut.value = MockEnvironment.staging #expect(spiceStore.userDefaults.string(forKey: "foo") == "staging") } @Test func it_publishes_initial_value() async throws { let spiceStore = MockSpiceStore() spiceStore.userDefaults.removeAll() spiceStore.userDefaults.set("Hello world!", forKey: "foo") let sut = UserDefaultsStorage(default: "default", key: nil) sut.prepare(propertyName: "foo", ownedBy: spiceStore) var readValue: String? _ = sut.publisher.sink { value in readValue = value } #expect(readValue == "Hello world!") } @Test func it_publishes_values() async throws { let spiceStore = MockSpiceStore() spiceStore.userDefaults.removeAll() let sut = UserDefaultsStorage(default: "foo", key: nil) sut.prepare(propertyName: "foo", ownedBy: spiceStore) let _: Void = try await withCheckedThrowingContinuation { @MainActor continuation in let timeoutTask = Task { // Wait for a second and if that time passes, assume we will not get notified, // in which case the test succeeded. try await Task.sleep(nanoseconds: 1_000_000_000) continuation.resume() } sut.publisher.sink { value in if value == "bar" { // We received the new value, so all is good. timeoutTask.cancel() continuation.resume() } } .store(in: &cancellables) // Setting the same value. This should not result in a value being published. sut.value = "bar" } } @Test func it_skips_publishing_same_value() async throws { let spiceStore = MockSpiceStore() spiceStore.userDefaults.removeAll() let sut = UserDefaultsStorage(default: "foo", key: nil) sut.prepare(propertyName: "foo", ownedBy: spiceStore) let _: Void = try await withCheckedThrowingContinuation { @MainActor continuation in let timeoutTask = Task { // Wait for a second and if that time passes, assume we will not get notified, // in which case the test succeeded. try await Task.sleep(nanoseconds: 1_000_000_000) continuation.resume() } var count = 0 sut.publisher.sink { value in count += 1 if count == 1 { // This is the initial value received upon subscribing. } else if count == 2 { // This is the second value. We did not expect this, so throw an error. timeoutTask.cancel() let error = NSError(domain: "dk.shape.Spices", code: -1, userInfo: [ NSLocalizedDescriptionKey: "Received unexpected value: \(value)" ]) continuation.resume(throwing: error) } } .store(in: &cancellables) // Setting the same value. This should not result in a value being published. sut.value = "foo" } } @Test func it_publishes_when_user_defaults_change() async throws { let spiceStore = MockSpiceStore() spiceStore.userDefaults.removeAll() let sut = UserDefaultsStorage(default: "foo", key: nil) sut.prepare(propertyName: "foo", ownedBy: spiceStore) let _: Void = try await withCheckedThrowingContinuation { @MainActor continuation in let timeoutTask = Task { // Wait for a second and if that time passes, assume we will not get notified, // in which case we throw an error as the test has failed. try await Task.sleep(nanoseconds: 1_000_000_000) let error = NSError(domain: "dk.shape.Spices", code: -1, userInfo: [ NSLocalizedDescriptionKey: "Operation timed out" ]) continuation.resume(throwing: error) } sut.publisher.sink { value in if value == "bar" { // We received the new value, so all is good. timeoutTask.cancel() continuation.resume() } } .store(in: &cancellables) // Updating UserDefaults should cause a value to be published. spiceStore.userDefaults.set("bar", forKey: "foo") } } @Test func it_skips_publishing_when_user_defaults_is_updated_with_same_value() async throws { let spiceStore = MockSpiceStore() spiceStore.userDefaults.removeAll() let sut = UserDefaultsStorage(default: "foo", key: nil) sut.prepare(propertyName: "foo", ownedBy: spiceStore) let _: Void = try await withCheckedThrowingContinuation { @MainActor continuation in let timeoutTask = Task { // Wait for a second and if that time passes, assume we will not get notified, // in which case the test succeeded. try await Task.sleep(nanoseconds: 1_000_000_000) continuation.resume() } var count = 0 sut.publisher.sink { value in count += 1 if count == 1 { // This is the initial value received upon subscribing. } else if count == 2 { // This is the second value. We did not expect this, so throw an error. timeoutTask.cancel() let error = NSError(domain: "dk.shape.Spices", code: -1, userInfo: [ NSLocalizedDescriptionKey: "Received unexpected value: \(value)" ]) continuation.resume(throwing: error) } } .store(in: &cancellables) // Assign the same value as we currentl have in user defaults. spiceStore.userDefaults.set("foo", forKey: "foo") } } }