Full Code of shapehq/spices for AI

main bb50b8335b7e cached
87 files
152.9 KB
39.9k tokens
1 requests
Download .txt
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
================================================
<!--- Provide a general summary of your changes in the Title above -->

## Description
<!--- Describe your changes in detail -->

## Motivation and Context
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->

## Screenshots (if appropriate):

## Types of changes
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] 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 = "<group>";
		};
		728FD6692D649EC8006B0CB2 /* UIKitExample */ = {
			isa = PBXFileSystemSynchronizedRootGroup;
			exceptions = (
				728FD67B2D649EC9006B0CB2 /* Exceptions for "UIKitExample" folder in "UIKitExample" target */,
			);
			path = UIKitExample;
			sourceTree = "<group>";
		};
/* 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 = "<group>";
		};
		725C9D2F2D5E26F100C79FDC /* Products */ = {
			isa = PBXGroup;
			children = (
				725C9D2E2D5E26F100C79FDC /* SwiftUIExample.app */,
				728FD6682D649EC8006B0CB2 /* UIKitExample.app */,
			);
			name = Products;
			sourceTree = "<group>";
		};
		728FD69A2D64AB1D006B0CB2 /* Frameworks */ = {
			isa = PBXGroup;
			children = (
			);
			name = Frameworks;
			sourceTree = "<group>";
		};
/* 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
================================================
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
   version = "1.0">
   <FileRef
      location = "self:">
   </FileRef>
</Workspace>


================================================
FILE: Examples/Example.xcodeproj/xcshareddata/xcschemes/SwiftUIExample.xcscheme
================================================
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
   LastUpgradeVersion = "1620"
   version = "1.7">
   <BuildAction
      parallelizeBuildables = "YES"
      buildImplicitDependencies = "YES"
      buildArchitectures = "Automatic">
      <BuildActionEntries>
         <BuildActionEntry
            buildForTesting = "YES"
            buildForRunning = "YES"
            buildForProfiling = "YES"
            buildForArchiving = "YES"
            buildForAnalyzing = "YES">
            <BuildableReference
               BuildableIdentifier = "primary"
               BlueprintIdentifier = "725C9D2D2D5E26F100C79FDC"
               BuildableName = "SwiftUIExample.app"
               BlueprintName = "SwiftUIExample"
               ReferencedContainer = "container:Example.xcodeproj">
            </BuildableReference>
         </BuildActionEntry>
      </BuildActionEntries>
   </BuildAction>
   <TestAction
      buildConfiguration = "Debug"
      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
      shouldUseLaunchSchemeArgsEnv = "YES"
      shouldAutocreateTestPlan = "YES">
   </TestAction>
   <LaunchAction
      buildConfiguration = "Debug"
      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
      launchStyle = "0"
      useCustomWorkingDirectory = "NO"
      ignoresPersistentStateOnLaunch = "NO"
      debugDocumentVersioning = "YES"
      debugServiceExtension = "internal"
      allowLocationSimulation = "YES">
      <BuildableProductRunnable
         runnableDebuggingMode = "0">
         <BuildableReference
            BuildableIdentifier = "primary"
            BlueprintIdentifier = "725C9D2D2D5E26F100C79FDC"
            BuildableName = "SwiftUIExample.app"
            BlueprintName = "SwiftUIExample"
            ReferencedContainer = "container:Example.xcodeproj">
         </BuildableReference>
      </BuildableProductRunnable>
   </LaunchAction>
   <ProfileAction
      buildConfiguration = "Release"
      shouldUseLaunchSchemeArgsEnv = "YES"
      savedToolIdentifier = ""
      useCustomWorkingDirectory = "NO"
      debugDocumentVersioning = "YES">
      <BuildableProductRunnable
         runnableDebuggingMode = "0">
         <BuildableReference
            BuildableIdentifier = "primary"
            BlueprintIdentifier = "725C9D2D2D5E26F100C79FDC"
            BuildableName = "SwiftUIExample.app"
            BlueprintName = "SwiftUIExample"
            ReferencedContainer = "container:Example.xcodeproj">
         </BuildableReference>
      </BuildableProductRunnable>
   </ProfileAction>
   <AnalyzeAction
      buildConfiguration = "Debug">
   </AnalyzeAction>
   <ArchiveAction
      buildConfiguration = "Release"
      revealArchiveInOrganizer = "YES">
   </ArchiveAction>
</Scheme>


================================================
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
================================================
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
    <dependencies>
        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
        <capability name="Safe area layout guides" minToolsVersion="9.0"/>
        <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
    </dependencies>
    <scenes>
        <!--View Controller-->
        <scene sceneID="EHf-IW-A2E">
            <objects>
                <viewController id="01J-lp-oVM" sceneMemberID="viewController">
                    <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
                        <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
                        <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
                        <color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
                        <viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
                    </view>
                </viewController>
                <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
            </objects>
            <point key="canvasLocation" x="53" y="375"/>
        </scene>
    </scenes>
</document>


================================================
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<Section, Item> {
        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<AnyCancellable> = []
    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<Section, Item>()
        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
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>UIApplicationSceneManifest</key>
	<dict>
		<key>UIApplicationSupportsMultipleScenes</key>
		<false/>
		<key>UISceneConfigurations</key>
		<dict>
			<key>UIWindowSceneSessionRoleApplication</key>
			<array>
				<dict>
					<key>UISceneConfigurationName</key>
					<string>Default Configuration</string>
					<key>UISceneDelegateClassName</key>
					<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
				</dict>
			</array>
		</dict>
	</dict>
</dict>
</plist>


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

<div align="center">
<h4>Spices makes it straightforward to create in-app debug menus by generating native UI from Swift.</h4>

<img src="spices.png" alt="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)
</div>

- [👋 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.

<img src="code.gif?raw=true" />


## 🚀 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.

<img src="/example.gif" width="300"/>

### 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<AnyCancellable> = []
    
    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

<a href="https://swiftpackageindex.com/shapehq/spices/documentation"><img src="https://swiftpackageindex.com/images/logo.svg" width="40" align="left" /></a>
<h3>The documentation is <a href="https://swiftpackageindex.com/shapehq/spices/documentation">available on Swift Package Index</a>.</h3>
<br/>

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<Spice.AsyncButtonHandler>
}


================================================
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<Spice.ButtonHandler>
}


================================================
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: "<unsupported>") {}
        }

        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<AnyCancellable> = []

    init<Value: RawRepresentable & CaseIterable>(
        name: Name,
        storage: AnyStorage<Value>,
        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<Value: CaseIterable & RawRepresentable>(
        from storage: AnyStorage<Value>
    ) -> (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<Value: CaseIterable & RawRepresentable>(in storage: AnyStorage<Value>) {
        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: RawRepresentable & CaseIterable>(_ value: Value, writingTo storage: AnyStorage<Value>) {
        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<String>
    private var cancellables: Set<AnyCancellable> = []

    init(name: Name, requiresRestart: Bool, storage: AnyStorage<String>) {
        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<Bool>
    private var cancellables: Set<AnyCancellable> = []

    init(name: Name, requiresRestart: Bool, storage: AnyStorage<Bool>) {
        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 ?? "<name unavailable>"
        }
        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<Value>: ObservableObject {
    private(set) var isPrepared = false
    let publisher: AnyPublisher<Value, Never>
    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<S: Storage>(_ 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<Value, Never> { get }
}


================================================
FILE: Sources/Spices/Internal/Storage/ThrowingStorage.swift
================================================
import Combine

final class ThrowingStorage<Value>: Storage {
    var value: Value {
        get {
            initialValue
        }
        // swiftlint:disable:next unused_setter_value
        set {
            fatalError(setterMessage)
        }
    }

    let publisher: AnyPublisher<Value, Never>

    private let initialValue: Value
    private let setterMessage: String
    private let passthroughSubject = PassthroughSubject<Value, Never>()

    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<Value>: Storage {
    var value: Value {
        get {
            backingValue
        }
        set {
            if !isValuesEqual(newValue, backingValue) {
                write?(newValue)
                spiceStoreOrThrow.publishObjectWillChange()
                backingValue = newValue
                subject.send(newValue)
            }
        }
    }
    var publisher: AnyPublisher<Value, Never> {
        subject.eraseToAnyPublisher()
    }

    private let subject: CurrentValueSubject<Value, Never>
    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<AnyCancellable> = []

    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<Bool>) -> 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<Bool>, 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<Content: View>: 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<AnyCancellable> = []
///
///     override func viewDidLoad() {
///         super.viewDidLoad()
///         spiceStore.$enableLogging
///             .sink { isEnabled in
///                 print("Is logging enabled: " + (isEnabled ? "👍" : "👎"))
///             }
///             .store(in: &cancellables)
///     }
/// }
/// ```
@propertyWrapper public struct Spice<Value> {
    /// 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<Value, Never> {
        storage.publisher
    }

    let name: Name
    let menuItem: any MenuItem

    private let storage: AnyStorage<Value>

    /// 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<T: SpiceStore>(
        _enclosingInstance instance: T,
        wrapped wrappedKeyPath: ReferenceWritableKeyPath<T, Value>,
        storage storageKeyPath: ReferenceWritableKeyPath<T, Self>
    ) -> 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<SpiceEditor> {
    /// 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 ?? "<name unavailable>"
        }
        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<T: SpiceStore>(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<T: SpiceStore>(editing spiceStore: T, title: String) -> some View {
        modifier(PresentSpiceEditorOnShakeViewModifier {
            SpiceEditor(editing: spiceStore, title: title)
        })
    }
}

private struct PresentSpiceEditorOnShakeViewModifier<Editor: View>: 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<AnyCancellable> = []

    @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<Value>: Storage, Preparable {
    var publisher: AnyPublisher<Value, Never> {
        subject.eraseToAnyPublisher()
    }
    var value: Value {
        get {
            subject.value
        }
        set {
            subject.send(newValue)
        }
    }

    private let subject: CurrentValueSubject<Value, Never>

    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<AnyCancellable> = []

    @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<AnyCancellable> = []

    @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<Value: RawRepresentable & CaseIterable>(
            default defaultValue: Value
        ) -> UserDefaultsStorage<Value> 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")
        }
    }
}
Download .txt
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
Condensed preview — 87 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (171K chars).
[
  {
    "path": ".github/CODEOWNERS",
    "chars": 72,
    "preview": "# https://help.github.com/en/articles/about-code-owners\n\n@shapehq/devops"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "chars": 955,
    "preview": "name: 🐛 Bug Report\ndescription: File a bug report.\nlabels: [\"bug\"]\nbody:\n  - type: markdown\n    attributes:\n      value:"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "chars": 1213,
    "preview": "name: ✨ Feature Request\ndescription: Suggest an idea for this project\nlabels: [\"feature\"]\nbody:\n  - type: markdown\n    a"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/question.yml",
    "chars": 485,
    "preview": "name: ❓ Question\ndescription: Ask a question about this project\nlabels: [\"question\"]\nbody:\n  - type: textarea\n    id: qu"
  },
  {
    "path": ".github/pull_request_template.md",
    "chars": 669,
    "preview": "<!--- Provide a general summary of your changes in the Title above -->\n\n## Description\n<!--- Describe your changes in de"
  },
  {
    "path": ".github/workflows/build.yml",
    "chars": 933,
    "preview": "name: Build\non:\n  workflow_dispatch: {}\n  pull_request:\n    types: [opened, synchronize]\npermissions: read-all\nconcurren"
  },
  {
    "path": ".github/workflows/build_documentation.yml",
    "chars": 867,
    "preview": "name: Build Documentation\non:\n  workflow_dispatch: {}\n  pull_request:\n    branches:\n      - main\npermissions: read-all\nc"
  },
  {
    "path": ".github/workflows/build_example_project.yml",
    "chars": 1913,
    "preview": "name: Build Example Project\non:\n  workflow_dispatch: {}\n  pull_request:\n    types: [opened, synchronize]\npermissions: re"
  },
  {
    "path": ".github/workflows/codeql.yml",
    "chars": 2684,
    "preview": "name: CodeQL\non:\n  push:\n    branches: [ \"main\" ]\n  pull_request:\n    branches: [ \"main\" ]\n  schedule:\n    - cron: '19 1"
  },
  {
    "path": ".github/workflows/swiftlint.yml",
    "chars": 489,
    "preview": "name: SwiftLint\non:\n  workflow_dispatch: {}\n  pull_request:\n    types: [opened, synchronize]\npermissions: read-all\nconcu"
  },
  {
    "path": ".github/workflows/test.yml",
    "chars": 776,
    "preview": "name: Run Tests\non:\n  workflow_dispatch:\n  pull_request:\n    types: [opened, synchronize]\npermissions: read-all\nenv:\n  X"
  },
  {
    "path": ".gitignore",
    "chars": 37,
    "preview": ".DS_Store\n.swiftpm\n.build\nxcuserdata\n"
  },
  {
    "path": ".spi.yml",
    "chars": 89,
    "preview": "version: 1\nbuilder:\n  configs:\n    - documentation_targets: [Spices]\n      platform: ios\n"
  },
  {
    "path": ".swiftlint.yml",
    "chars": 1777,
    "preview": "disabled_rules:\n  - nesting\nopt_in_rules:\n  - anonymous_argument_in_multiline_closure\n  - array_init\n  - collection_alig"
  },
  {
    "path": "Examples/Example.xcodeproj/project.pbxproj",
    "chars": 20594,
    "preview": "// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {\n\t};\n\tobjectVersion = 77;\n\tobjects = {\n\n/* Begin PBXBuildFile section *"
  },
  {
    "path": "Examples/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata",
    "chars": 135,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace\n   version = \"1.0\">\n   <FileRef\n      location = \"self:\">\n   </FileRef"
  },
  {
    "path": "Examples/Example.xcodeproj/xcshareddata/xcschemes/SwiftUIExample.xcscheme",
    "chars": 2923,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"1620\"\n   version = \"1.7\">\n   <BuildAction\n      "
  },
  {
    "path": "Examples/SwiftUIExample/AppSpiceStore.swift",
    "chars": 1139,
    "preview": "import Foundation\nimport Spices\nimport SwiftUI\n\nenum ServiceEnvironment: String, CaseIterable {\n    case production\n    "
  },
  {
    "path": "Examples/SwiftUIExample/Assets.xcassets/AccentColor.colorset/Contents.json",
    "chars": 123,
    "preview": "{\n  \"colors\" : [\n    {\n      \"idiom\" : \"universal\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }"
  },
  {
    "path": "Examples/SwiftUIExample/Assets.xcassets/AppIcon.appiconset/Contents.json",
    "chars": 724,
    "preview": "{\n  \"images\" : [\n    {\n      \"filename\" : \"AppIconLight.png\",\n      \"idiom\" : \"universal\",\n      \"platform\" : \"ios\",\n   "
  },
  {
    "path": "Examples/SwiftUIExample/Assets.xcassets/Contents.json",
    "chars": 63,
    "preview": "{\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "Examples/SwiftUIExample/ContentView.swift",
    "chars": 2483,
    "preview": "import Spices\nimport SwiftUI\n\nstruct ContentView: View {\n    @EnvironmentObject private var spiceStore: AppSpiceStore\n  "
  },
  {
    "path": "Examples/SwiftUIExample/ExampleApp.swift",
    "chars": 184,
    "preview": "import SwiftUI\n\n@main\nstruct ExampleApp: App {\n    var body: some Scene {\n        WindowGroup {\n            ContentView("
  },
  {
    "path": "Examples/SwiftUIExample/Preview Content/Preview Assets.xcassets/Contents.json",
    "chars": 63,
    "preview": "{\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "Examples/UIKitExample/AppDelegate.swift",
    "chars": 271,
    "preview": "import UIKit\n\n@main\nfinal class AppDelegate: UIResponder, UIApplicationDelegate {\n    func application(\n        _ applic"
  },
  {
    "path": "Examples/UIKitExample/AppSpiceStore.swift",
    "chars": 1218,
    "preview": "import Foundation\nimport Spices\nimport SwiftUI\n\nenum ServiceEnvironment: String, CaseIterable {\n    case production\n    "
  },
  {
    "path": "Examples/UIKitExample/Assets.xcassets/AccentColor.colorset/Contents.json",
    "chars": 123,
    "preview": "{\n  \"colors\" : [\n    {\n      \"idiom\" : \"universal\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }"
  },
  {
    "path": "Examples/UIKitExample/Assets.xcassets/AppIcon.appiconset/Contents.json",
    "chars": 724,
    "preview": "{\n  \"images\" : [\n    {\n      \"filename\" : \"AppIconLight.png\",\n      \"idiom\" : \"universal\",\n      \"platform\" : \"ios\",\n   "
  },
  {
    "path": "Examples/UIKitExample/Assets.xcassets/Contents.json",
    "chars": 63,
    "preview": "{\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "Examples/UIKitExample/Base.lproj/LaunchScreen.storyboard",
    "chars": 1665,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n<document type=\"com.apple.InterfaceBuilder3.CocoaTouch.Storyboard"
  },
  {
    "path": "Examples/UIKitExample/ContentViewController.swift",
    "chars": 7092,
    "preview": "import Combine\nimport Spices\nimport UIKit\n\nfinal class ContentViewController: UIViewController {\n    private enum Sectio"
  },
  {
    "path": "Examples/UIKitExample/Info.plist",
    "chars": 639,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
  },
  {
    "path": "Examples/UIKitExample/SceneDelegate.swift",
    "chars": 1110,
    "preview": "import Spices\nimport UIKit\n\nfinal class SceneDelegate: UIResponder, UIWindowSceneDelegate {\n    var window: UIWindow?\n\n "
  },
  {
    "path": "LICENSE",
    "chars": 1066,
    "preview": "MIT License\n\nCopyright (c) 2025 Shape ApS\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\n"
  },
  {
    "path": "Package.swift",
    "chars": 463,
    "preview": "// swift-tools-version: 6.0\n// The swift-tools-version declares the minimum version of Swift required to build this pack"
  },
  {
    "path": "README.md",
    "chars": 16973,
    "preview": "# 🫙🌶 Spices\n\n<div align=\"center\">\n<h4>Spices makes it straightforward to create in-app debug menus by generating native "
  },
  {
    "path": "SECURITY.md",
    "chars": 193,
    "preview": "# Security Policy\n\n## Reporting a Vulnerability\n\nPlease contact us at security@shape.dk if you find a security vulnerabi"
  },
  {
    "path": "Sources/Spices/Internal/MenuItems/AsyncButtonMenuItem.swift",
    "chars": 194,
    "preview": "import Foundation\n\nstruct AsyncButtonMenuItem: MenuItem {\n    let id = UUID().uuidString\n    let name: Name\n    let requ"
  },
  {
    "path": "Sources/Spices/Internal/MenuItems/ButtonMenuItem.swift",
    "chars": 184,
    "preview": "import Foundation\n\nstruct ButtonMenuItem: MenuItem {\n    let id = UUID().uuidString\n    let name: Name\n    let requiresR"
  },
  {
    "path": "Sources/Spices/Internal/MenuItems/ChildSpiceStoreMenuItem.swift",
    "chars": 321,
    "preview": "import Foundation\n\nstruct ChildSpiceStoreMenuItem: MenuItem {\n    enum PresentationStyle {\n        case modal\n        ca"
  },
  {
    "path": "Sources/Spices/Internal/MenuItems/MenuItem.swift",
    "chars": 49,
    "preview": "protocol MenuItem {\n    var id: String { get }\n}\n"
  },
  {
    "path": "Sources/Spices/Internal/MenuItems/MenuItemProvider.swift",
    "chars": 69,
    "preview": "protocol MenuItemProvider {\n    var menuItem: any MenuItem { get }\n}\n"
  },
  {
    "path": "Sources/Spices/Internal/MenuItems/PickerMenuItem.swift",
    "chars": 2986,
    "preview": "import Combine\nimport Foundation\n\nfinal class PickerMenuItem: MenuItem, ObservableObject {\n    struct Option: Hashable, "
  },
  {
    "path": "Sources/Spices/Internal/MenuItems/TextFieldMenuItem.swift",
    "chars": 851,
    "preview": "import Combine\nimport Foundation\n\nfinal class TextFieldMenuItem: MenuItem, ObservableObject {\n    let id = UUID().uuidSt"
  },
  {
    "path": "Sources/Spices/Internal/MenuItems/ToggleMenuItem.swift",
    "chars": 842,
    "preview": "import Combine\nimport Foundation\n\nfinal class ToggleMenuItem: MenuItem, ObservableObject {\n    let id = UUID().uuidStrin"
  },
  {
    "path": "Sources/Spices/Internal/MenuItems/ViewMenuItem.swift",
    "chars": 479,
    "preview": "import Foundation\nimport SwiftUI\n\nfinal class ViewMenuItem: MenuItem {\n    enum PresentationStyle {\n        case modal\n "
  },
  {
    "path": "Sources/Spices/Internal/Name.swift",
    "chars": 336,
    "preview": "final class Name {\n    var rawValue: String {\n        get {\n            explicitValue ?? _value ?? \"<name unavailable>\"\n"
  },
  {
    "path": "Sources/Spices/Internal/Preparable.swift",
    "chars": 99,
    "preview": "protocol Preparable {\n    func prepare(propertyName: String, ownedBy spiceStore: any SpiceStore)\n}\n"
  },
  {
    "path": "Sources/Spices/Internal/Storage/AnyStorage.swift",
    "chars": 881,
    "preview": "import Combine\n\nfinal class AnyStorage<Value>: ObservableObject {\n    private(set) var isPrepared = false\n    let publis"
  },
  {
    "path": "Sources/Spices/Internal/Storage/Storage.swift",
    "chars": 172,
    "preview": "import Combine\n\nprotocol Storage: AnyObject, Preparable {\n    associatedtype Value\n    var value: Value { get set }\n    "
  },
  {
    "path": "Sources/Spices/Internal/Storage/ThrowingStorage.swift",
    "chars": 751,
    "preview": "import Combine\n\nfinal class ThrowingStorage<Value>: Storage {\n    var value: Value {\n        get {\n            initialVa"
  },
  {
    "path": "Sources/Spices/Internal/Storage/UserDefaultsStorage.swift",
    "chars": 4068,
    "preview": "import Combine\nimport Foundation\n\nfinal class UserDefaultsStorage<Value>: Storage {\n    var value: Value {\n        get {"
  },
  {
    "path": "Sources/Spices/Internal/String+Helpers.swift",
    "chars": 1963,
    "preview": "import Foundation\n\nextension String {\n    func removing(prefix: String) -> Self {\n        guard hasPrefix(prefix) else {"
  },
  {
    "path": "Sources/Spices/Internal/UIApplication+Helpers.swift",
    "chars": 1449,
    "preview": "#if canImport(UIKit)\nimport UIKit\n\nextension UIApplication {\n    func shp_restart() {\n        let topViewController = sh"
  },
  {
    "path": "Sources/Spices/Internal/UIViewController+Helpers.swift",
    "chars": 399,
    "preview": "#if canImport(UIKit)\nimport UIKit\n\nextension UIViewController {\n    // swiftlint:disable:next identifier_name\n    var sh"
  },
  {
    "path": "Sources/Spices/Internal/View+RestartOnChange.swift",
    "chars": 506,
    "preview": "#if canImport(UIKit)\nimport SwiftUI\n\npublic extension View {\n    func restartApp(_ isActive: Binding<Bool>) -> some View"
  },
  {
    "path": "Sources/Spices/Internal/Views/AsyncButtonMenuItemView.swift",
    "chars": 1262,
    "preview": "import SwiftUI\n\nstruct AsyncButtonMenuItemView: View {\n    let menuItem: AsyncButtonMenuItem\n\n    @EnvironmentObject pri"
  },
  {
    "path": "Sources/Spices/Internal/Views/ButtonMenuItemView.swift",
    "chars": 656,
    "preview": "import SwiftUI\n\nstruct ButtonMenuItemView: View {\n    let menuItem: ButtonMenuItem\n\n    @State private var isErrorPresen"
  },
  {
    "path": "Sources/Spices/Internal/Views/ChildSpiceStoreMenuItemView.swift",
    "chars": 2027,
    "preview": "import SwiftUI\n\nstruct ChildSpiceStoreMenuItemView: View {\n    let menuItem: ChildSpiceStoreMenuItem\n    let dismiss: ()"
  },
  {
    "path": "Sources/Spices/Internal/Views/MenuItemListContent.swift",
    "chars": 268,
    "preview": "import SwiftUI\n\nstruct MenuItemListContent: View {\n    let menuItems: [MenuItem]\n    let dismiss: () -> Void\n\n    var bo"
  },
  {
    "path": "Sources/Spices/Internal/Views/MenuItemListView.swift",
    "chars": 863,
    "preview": "import SwiftUI\n\nstruct MenuItemListView: View {\n    @EnvironmentObject private var userInteraction: UserInteraction\n    "
  },
  {
    "path": "Sources/Spices/Internal/Views/MenuItemView.swift",
    "chars": 1078,
    "preview": "import SwiftUI\n\nstruct MenuItemView: View {\n    let menuItem: any MenuItem\n    let dismiss: () -> Void\n\n    var body: so"
  },
  {
    "path": "Sources/Spices/Internal/Views/PickerMenuItemView.swift",
    "chars": 1011,
    "preview": "import SwiftUI\n\nstruct PickerMenuItemView: View {\n    @ObservedObject private var menuItem: PickerMenuItem\n    @State pr"
  },
  {
    "path": "Sources/Spices/Internal/Views/TextFieldMenuItemView.swift",
    "chars": 884,
    "preview": "import SwiftUI\n\nstruct TextFieldMenuItemView: View {\n    @ObservedObject var menuItem: TextFieldMenuItem\n\n    @State pri"
  },
  {
    "path": "Sources/Spices/Internal/Views/ToggleMenuItemView.swift",
    "chars": 739,
    "preview": "import SwiftUI\n\nstruct ToggleMenuItemView: View {\n    @ObservedObject var menuItem: ToggleMenuItem\n\n    @State private v"
  },
  {
    "path": "Sources/Spices/Internal/Views/UserInteraction.swift",
    "chars": 102,
    "preview": "import SwiftUI\n\nfinal class UserInteraction: ObservableObject {\n    @Published var isEnabled = true\n}\n"
  },
  {
    "path": "Sources/Spices/Internal/Views/View+ConfigureSheetPresentation.swift",
    "chars": 456,
    "preview": "import SwiftUI\n\n@available(iOS 16, *)\nprivate struct ConfigureSheetPresentationViewModifier: ViewModifier {\n    func bod"
  },
  {
    "path": "Sources/Spices/Internal/Views/View+ErrorAlert.swift",
    "chars": 810,
    "preview": "import SwiftUI\n\nextension View {\n    func errorAlert(isPresented: Binding<Bool>, showing error: Error? = nil) -> some Vi"
  },
  {
    "path": "Sources/Spices/Internal/Views/ViewMenuItemView.swift",
    "chars": 1037,
    "preview": "import SwiftUI\n\nstruct ViewMenuItemView: View {\n    let menuItem: ViewMenuItem\n\n    var body: some View {\n        switch"
  },
  {
    "path": "Sources/Spices/PresentationStyle.swift",
    "chars": 2793,
    "preview": "/// A type that represents different styles for presenting a view within the in-app debug menu.\npublic protocol Presenta"
  },
  {
    "path": "Sources/Spices/Spice.swift",
    "chars": 17988,
    "preview": "// swiftlint:disable file_length\nimport Combine\nimport Foundation\nimport SwiftUI\n\n/// A property wrapper for exposing se"
  },
  {
    "path": "Sources/Spices/SpiceEditor.swift",
    "chars": 2353,
    "preview": "import SwiftUI\n\n/// A SwiftUI view that presents an in-app debug menu for editing settings managed by a ``SpiceStore``.\n"
  },
  {
    "path": "Sources/Spices/SpiceEditorViewController.swift",
    "chars": 1658,
    "preview": "#if canImport(UIKit)\nimport SwiftUI\nimport UIKit\n\n/// A UIKit view controller that hosts a ``SpiceEditor`` view for edit"
  },
  {
    "path": "Sources/Spices/SpiceEditorWindow.swift",
    "chars": 4156,
    "preview": "#if canImport(UIKit)\nimport SwiftUI\nimport UIKit\n\n/// A `UIWindow` subclass that facilitates the presentation of an in-a"
  },
  {
    "path": "Sources/Spices/SpiceStore.swift",
    "chars": 4703,
    "preview": "import Combine\nimport Foundation\nimport ObjectiveC\n\nnonisolated(unsafe) private var idKey: UInt8 = 0\nnonisolated(unsafe)"
  },
  {
    "path": "Sources/Spices/Spices.docc/Extensions/SpiceStore.md",
    "chars": 125,
    "preview": "# ``SpiceStore``\n\n## Topics\n\n### Storage\n\n- ``SpiceStore/userDefaults``\n\n### Preparation\n\n- ``SpiceStore/prepareIfNeeded"
  },
  {
    "path": "Sources/Spices/Spices.docc/Spices.md",
    "chars": 1192,
    "preview": "# ``Spices``\n\nSpices makes it straightforward to create in-app debug menus by generating native UI from Swift.\n\n## Overv"
  },
  {
    "path": "Sources/Spices/SpicesTitleProvider.swift",
    "chars": 959,
    "preview": "/// A protocol for providing custom titles for enum values used with a ``Spice`` property wrapper.\n///\n/// By default, S"
  },
  {
    "path": "Sources/Spices/View+SpiceEditor.swift",
    "chars": 4706,
    "preview": "#if canImport(UIKit)\nimport SwiftUI\n\npublic extension View {\n    /// Presents a ``SpiceEditor`` for the given ``SpiceSto"
  },
  {
    "path": "Tests/SpicesTests/AnyStorageTests.swift",
    "chars": 1297,
    "preview": "import Combine\nimport Foundation\n@testable import Spices\nimport Testing\n\n@Suite\nfinal class AnyStorageTests {\n    privat"
  },
  {
    "path": "Tests/SpicesTests/CamelCaseToNaturalTextTests.swift",
    "chars": 1084,
    "preview": "import Foundation\n@testable import Spices\nimport Testing\n\n@Suite\nstruct CamelCaseToNaturalTextTests {\n    @Test\n    func"
  },
  {
    "path": "Tests/SpicesTests/Helpers/UserDefaults+Helpers.swift",
    "chars": 174,
    "preview": "import Foundation\n\nextension UserDefaults {\n    func removeAll() {\n        let domain = Bundle.main.bundleIdentifier!\n  "
  },
  {
    "path": "Tests/SpicesTests/Mocks/MockEnvironment.swift",
    "chars": 84,
    "preview": "enum MockEnvironment: String, CaseIterable {\n    case production\n    case staging\n}\n"
  },
  {
    "path": "Tests/SpicesTests/Mocks/MockSpiceStore.swift",
    "chars": 578,
    "preview": "import Spices\n\nfinal class MockSpiceStore: SpiceStore {\n    nonisolated(unsafe) static var buttonClosureCalled = false\n "
  },
  {
    "path": "Tests/SpicesTests/Mocks/MockStorage.swift",
    "chars": 550,
    "preview": "import Combine\n@testable import Spices\n\nfinal class MockStorage<Value>: Storage, Preparable {\n    var publisher: AnyPubl"
  },
  {
    "path": "Tests/SpicesTests/SpiceTests.swift",
    "chars": 3378,
    "preview": "import Combine\nimport Foundation\n@testable import Spices\nimport Testing\n\n@MainActor @Suite(.serialized)\nfinal class Spic"
  },
  {
    "path": "Tests/SpicesTests/UserDefaultsStorageTests.swift",
    "chars": 8046,
    "preview": "import Combine\nimport Foundation\n@testable import Spices\nimport Testing\n\n@MainActor @Suite(.serialized)\nfinal class User"
  }
]

About this extraction

This page contains the full source code of the shapehq/spices GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 87 files (152.9 KB), approximately 39.9k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!