Skip to content
/ spices Public

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

License

Notifications You must be signed in to change notification settings

shapehq/spices

Repository files navigation

🫙🌶 Spices

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

Logo for Spices. A wooden shelf holds four glass jars filled with different spices. Above the shelf is a circular sign with the word 'Spices' and a red crossed-out bug.


Build Build Example Project SwiftLint
Run Tests Build Documentation CodeQL

👋 Introduction

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 (becoming Framna) to provide a frictionless API for quickly creating these menus. Common use cases include environment switching, resetting state, and enabling features during development.

🚀 Getting Started

This section details the steps needed to add an in-app debug menu using Spices.

Step 1: Add the Spices Swift Package

Add Spices to your Xcode project or Swift package.

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

enum ServiceEnvironment: String, CaseIterable {
    case production
    case staging
}

class AppSpiceStore: SpiceStore {
    @Spice(requiresRestart: true) var environment: ServiceEnvironment = .production
    @Spice var enableLogging = false
    @Spice var clearCache = {
        try await Task.sleep(for: .seconds(1))
        URLCache.shared.removeAllCachedResponses()
    }
    @Spice var featureFlags = FeatureFlagsSpiceStore()
}

class FeatureFlagsSpiceStore: SpiceStore {
    @Spice var notifications = false
    @Spice var fastRefreshWidgets = false
}

Based on the above code, Spices will generate an in-app debug menu like the one shown below.

Step 3: Present the In-App Debug Menu

The app must be configured to display the spice editor. The approach depends on whether your app is using a SwiftUI or UIKit lifecycle.

Warning

The in-app debug menu may contain sensitive information. Ensure it's only accessible in debug and beta builds by excluding the menu's presentation code from release builds using conditional compilation (e.g., #if DEBUG). The examples in this section demonstrate this technique.

SwiftUI Lifecycle

Use the presentSpiceEditorOnShake(_:) view modifier to show the editor when the device is shaken.

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.

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.

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.

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:

AppSpiceStore.environment

SwiftUI Lifecycle

Spice stores conforming to the SpiceStore protocol also conform to ObservableObject, and as such, can be observed from SwiftUI using StateObject, ObservedObject, or EnvironmentObject.

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.

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 folder shows how Spices can be used to add an in-app debug menu to iOS apps with SwiftUI and UIKit lifecycles.

📖 Documentation

The documentation is available on Swift Package Index.


The following sections document select APIs and use cases.

Toggles

Toggles are created for boolean variables in a spice store.

@Spice var enableLogging = false

Pickers

Pickers are created for types conforming to both RawRepresentable and CaseIterable. This is typically enums.

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.

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.

@Spice var clearCache = {
    URLCache.shared.removeAllCachedResponses()
}

Providing an asynchronous closure causes a loading indicator to be displayed for the duration of the operation.

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

@Spice var url = "http://example.com"

Group Settings Using Nested Spice Stores

Spice stores can be nested to create a hierarchical user interface.

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:

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

@Spice(presentation: .modal) var featureFlags = FeatureFlagsSpiceStore()

Alternatively, it can be displayed as an inlined section within the settings list:

@Spice(presentation: .inline) var featureFlags = FeatureFlagsSpiceStore()

When inlining a nested spice store, a header and footer can be provided for better context:

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

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

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

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

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

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

.presentSpiceEditorOnShake(editing: spiceStore, title: "Config")

The title can also be specified when manually creating and presenting an instance of SpiceEditor.

SpiceEditor(editing: spiceStore, title: "Config")

UIKit Lifecycle

The SpiceEditorWindow can be initialized with a title as follows.

SpiceEditorWindow(windowScene: windowScene, editing: AppSpiceStore.shared, title: "Config")

The title can also be specified when manually creating and presenting an instance of SpiceEditorViewController.

let viewController = SpiceEditorViewController(editing: AppSpiceStore.shared, title: "Config")

Store Values in Custom UserDefaults

By default, values are stored in UserDefaults.standard. To use a different UserDefaults instance, such as for sharing data with an app group, implement the userDefaults property of SpiceStore.

class AppSpiceStore: SpiceStore {
    let userDefaults = UserDefaults(suiteName: "group.dk.shape.example")
}

Store Values Under Custom Key

Values are stored in 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.

@Spice(key: "env") var environment: ServiceEnvironment = .production

Using with @AppStorage

Values are stored in UserDefaults and can be used with @AppStorage for seamless integration in SwiftUI.

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 (becoming Framna) 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.