From boutique
Persist values in Swift apps using Boutique's @StoredValue (UserDefaults) and @SecurelyStoredValue (Keychain). Supports set, reset, toggle, keypath setters, array/dictionary helpers, async observation for preferences, settings, feature flags, auth tokens.
npx claudepluginhub mergesort/boutique --plugin boutiqueThis skill uses the workspace's default tool permissions.
Use this skill when you need to persist individual values (preferences, settings, feature flags) using `@StoredValue` (backed by UserDefaults) or sensitive data (auth tokens, passwords) using `@SecurelyStoredValue` (backed by the system Keychain).
Integrate Boutique stores with SwiftUI views using onChange, onStoreDidLoad, bindings, and preview stores for displaying and reacting to persisted data.
Guides secure storage/retrieval of credentials in iOS/macOS keychain using SecItem API. Debugs errors like errSecDuplicateItem, manages access groups, biometrics, and app sharing.
Implements SettingsKit for SwiftUI settings interfaces with searchable settings, nested navigation, @Observable/@Bindable state on iOS/macOS/watchOS/tvOS/visionOS. Use for preferences screens or settings errors.
Share bugs, ideas, or general feedback.
Use this skill when you need to persist individual values (preferences, settings, feature flags) using @StoredValue (backed by UserDefaults) or sensitive data (auth tokens, passwords) using @SecurelyStoredValue (backed by the system Keychain).
Codable, Sendable, and Equatable.@StoredValue(key: "hasHapticsEnabled")
var hasHapticsEnabled = false
@StoredValue(key: "lastOpenedDate")
var lastOpenedDate: Date? = nil
@StoredValue(key: "currentTheme")
var currentlySelectedTheme: Theme = .light
Always pair with @ObservationIgnored to prevent duplicate observation tracking.
@Observable
final class Preferences {
@ObservationIgnored
@StoredValue(key: "hasHapticsEnabled")
var hasHapticsEnabled = false
@ObservationIgnored
@StoredValue(key: "lastOpenedDate")
var lastOpenedDate: Date? = nil
@ObservationIgnored
@StoredValue(key: "currentTheme")
var currentlySelectedTheme: Theme = .light
}
Use the $ projected value to access set and reset.
// Set a new value
$lastOpenedDate.set(.now)
$currentlySelectedTheme.set(.dark)
// Reset to the default value provided at declaration
$lastOpenedDate.reset() // Back to nil
$currentlySelectedTheme.reset() // Back to .light
$hasHapticsEnabled.toggle()
// Equivalent to:
// $hasHapticsEnabled.set(!hasHapticsEnabled)
Update a single property inside a complex stored object without manually copying.
struct UserPreferences: Codable, Sendable, Equatable {
var hasHapticsEnabled: Bool
var prefersDarkMode: Bool
var prefersWideScreen: Bool
}
@ObservationIgnored
@StoredValue(key: "userPreferences")
var preferences = UserPreferences(
hasHapticsEnabled: true,
prefersDarkMode: false,
prefersWideScreen: false
)
// Update a single nested property
$preferences.set(\.prefersDarkMode, to: true)
When a @StoredValue holds an array, convenience methods are available.
@ObservationIgnored
@StoredValue(key: "favoriteTags")
var favoriteTags: [String] = []
// Append an element
$favoriteTags.append("swift")
// Toggle presence (add if missing, remove if present)
$favoriteTags.togglePresence("swift")
// Replace an element
$favoriteTags.replace("swfit", with: "swift")
@ObservationIgnored
@StoredValue(key: "featureFlags")
var featureFlags: [String: Bool] = [:]
// Update a key
$featureFlags.update(key: "darkMode", value: true)
// Remove a key by setting nil
$featureFlags.update(key: "darkMode", value: nil)
Observe changes over time with the values AsyncStream.
func monitorThemeChanges() async {
for await theme in preferences.$currentlySelectedTheme.values {
print("Theme changed to", theme)
}
}
@StoredValue(key: "sharedSetting", storage: UserDefaults(suiteName: "group.com.example.app")!)
var sharedSetting = false
Useful in contexts where property wrappers are not supported.
let hasHapticsEnabled = StoredValue(key: "hasHapticsEnabled", default: false)
| Aspect | @StoredValue | @SecurelyStoredValue |
|---|---|---|
| Backing store | UserDefaults | System Keychain |
| Default value | Required | Not supported |
wrappedValue type | Item | Item? (always optional) |
| Mutation methods | set(_:), reset() | set(_:) throws, remove() throws |
| Use case | Preferences, settings | Passwords, tokens, secrets |
Do not make the type optional yourself, the wrapper handles that. Declaring @SecurelyStoredValue<String?> creates a double optional.
@Observable
final class SecurityManager {
@ObservationIgnored
@SecurelyStoredValue<String>(key: "authToken")
var authToken
@ObservationIgnored
@SecurelyStoredValue<String>(key: "refreshToken")
var refreshToken
}
// Set a value (throws on keychain errors)
try $authToken.set("eyJhbGciOiJIUzI1NiIs...")
// Remove from keychain
try $authToken.remove()
// Set to nil (same as remove)
try $authToken.set(nil)
@SecurelyStoredValue<String>(
key: "authToken",
service: KeychainService(value: "com.example.auth"),
group: KeychainGroup(value: "group.com.example.shared")
)
var authToken
@ObservationIgnored
@SecurelyStoredValue<Bool>(key: "biometricsEnabled")
var biometricsEnabled
try $biometricsEnabled.toggle()
@ObservationIgnored
@SecurelyStoredValue<[String]>(key: "trustedDevices")
var trustedDevices
try $trustedDevices.append("device-abc-123")
try $trustedDevices.replace("device-old", with: "device-new")
try $credentials.set(\.accessToken, to: "new-token")
func monitorAuthState() async {
for await token in securityManager.$authToken.values {
if let token {
print("Authenticated")
} else {
print("Logged out")
}
}
}
For apps with many preferences, break them into focused @Observable classes.
@Observable
final class Preferences {
var userExperience = UserExperiencePreferences()
var notifications = NotificationPreferences()
}
@Observable
final class UserExperiencePreferences {
@ObservationIgnored
@StoredValue(key: "hasSoundEffectsEnabled")
var hasSoundEffectsEnabled = false
@ObservationIgnored
@StoredValue(key: "hasHapticsEnabled")
var hasHapticsEnabled = true
}
@Observable
final class NotificationPreferences {
@ObservationIgnored
@StoredValue(key: "pushEnabled")
var pushEnabled = true
@ObservationIgnored
@StoredValue(key: "emailDigestEnabled")
var emailDigestEnabled = false
}
$: Use $storedValue.set(value), not storedValue.set(value). The wrappedValue is the raw value; the projectedValue (via $) is the StoredValue with mutation methods.@ObservationIgnored: Always add @ObservationIgnored before @StoredValue or @SecurelyStoredValue in @Observable classes.@SecurelyStoredValue<String?>. The wrapper already makes wrappedValue optional.@StoredValue and @SecurelyStoredValue are both @MainActor isolated.@StoredValue are available synchronously on app launch.@SecurelyStoredValue are read from the Keychain synchronously.boutique-swiftui skill for using .binding with SwiftUI controls.