Managing Application Preferences in OpenSuperWhisper: A Swift Best Practices Guide
OpenSuperWhisper centralizes all user settings in a type-safe AppPreferences singleton that uses property wrappers for compile-time safety, automatic migration for schema changes, and NotificationCenter for decoupled UI updates.
Managing application preferences effectively is crucial for maintaining clean architecture in macOS apps. In OpenSuperWhisper, all user-configurable settings are handled through a centralized singleton defined in OpenSuperWhisper/Utils/AppPreferences.swift, eliminating string-typed keys and scattered state. This approach demonstrates modern Swift patterns for managing application preferences that ensure consistency across the Settings UI, transcription engines, and global hotkey handlers.
Centralizing State with a Singleton Pattern
The foundation of preference management in OpenSuperWhisper is the AppPreferences singleton. Rather than accessing UserDefaults directly throughout the codebase, every component reads from and writes to AppPreferences.shared, creating a single source of truth for all configuration state.
Single Source of Truth
All settings are ultimately stored in UserDefaults, but the singleton acts as the exclusive gateway. This guarantees that every part of the application—from the transcription engine to the settings panel—reads identical values. For example, WhisperEngine.swift and FluidAudioEngine.swift both query AppPreferences.shared to locate model files, ensuring engine selection remains synchronized.
Type Safety Through Property Wrappers
Preference keys are never raw strings scattered through the code. Instead, OpenSuperWhisper implements custom property wrappers (@UserDefault and @OptionalUserDefault) that map Swift types to storage keys at compile time.
// Reading a preference returns the correct type immediately
let language = AppPreferences.shared.whisperLanguage // "en" by default
// Writing updates UserDefaults automatically through the wrapper
AppPreferences.shared.translateToEnglish = true
This design prevents type mismatches and eliminates the need for manual casting or key management throughout the application.
Handling Schema Changes and Default Values
Automatic Migration
When the preference schema changes, AppPreferences.init() calls migrateOldPreferences() to lazily migrate legacy keys. This ensures existing users retain their settings after updates without requiring manual intervention or data loss.
Consistent Defaults
Every property declares a sensible default value (e.g., translateToEnglish: false, temperature: 0.0). This guarantees the app functions out-of-the-box and makes unit testing deterministic by ensuring predictable initial states.
Encapsulating Complex Logic
Derived Preferences
Some preferences depend on others, and OpenSuperWhisper encapsulates this logic within the preferences class rather than duplicating it in UI or engine layers. For example, selectedModelPath forwards to selectedWhisperModelPath when the engine is set to whisper.
// UI code remains agnostic about which underlying key to query
if let modelPath = AppPreferences.shared.selectedModelPath {
// Use the path for transcription regardless of engine type
}
This abstraction prevents view controllers and engine implementations from needing to know about the internal preference schema.
Decoupling UI with Notifications
Changes that affect the UI—such as language changes—are broadcast through NotificationCenter using constants defined in OpenSuperWhisper/Utils/NotificationName+App.swift. Consumers subscribe only to notifications they care about, keeping the preference store free of UI code.
NotificationCenter.default.addObserver(
forName: .appPreferencesLanguageChanged,
object: nil,
queue: .main
) { _ in
// Reload UI strings for the new language
self.reloadLocalizedStrings()
}
Integration Points Across the Application
Settings UI
The preferences user interface in Settings.swift reads and writes properties directly through the singleton:
AppPreferences.shared.selectedEngine = newEngine
Transcription Engines
Both WhisperEngine.swift and FluidAudioEngine.swift query specific preferences to locate their respective resources. The Whisper engine accesses AppPreferences.shared.selectedWhisperModelPath, while the FluidAudio engine checks AppPreferences.shared.fluidAudioModelVersion.
Hotkey Management
ShortcutManager.swift pulls configuration values directly from the singleton to register global shortcuts:
let modifierHotkey = AppPreferences.shared.modifierOnlyHotkey
let mouseHotkey = AppPreferences.shared.mouseButtonHotkey
Testing Strategy
The singleton can be reset in tests by clearing UserDefaults and reinstantiating AppPreferences.shared. Since all preferences are value types with explicit defaults, assertions remain trivial and deterministic.
func testLanguagePreference() {
// Reset defaults for a clean test environment
UserDefaults.standard.removePersistentDomain(forName: Bundle.main.bundleIdentifier!)
// Initialize the shared instance (migration runs automatically)
let prefs = AppPreferences.shared
XCTAssertEqual(prefs.whisperLanguage, "en") // default
prefs.whisperLanguage = "ja"
XCTAssertEqual(prefs.whisperLanguage, "ja")
}
Summary
- Use a singleton (
AppPreferences.shared) as the single source of truth for all settings, defined inOpenSuperWhisper/Utils/AppPreferences.swift - Implement property wrappers (
@UserDefault,@OptionalUserDefault) to ensure compile-time type safety and eliminate string-typed keys - Provide default values for every preference to guarantee out-of-the-box functionality and deterministic testing
- Handle schema migrations lazily in the initializer via
migrateOldPreferences()to preserve user data across updates - Encapsulate derived values within the preferences class to prevent logic duplication across UI and engine layers
- Use NotificationCenter (via
NotificationName+App.swift) to broadcast changes without coupling the storage layer to UI code - Access preferences directly in engines, UI, and hotkey managers via the shared singleton to maintain consistency
Frequently Asked Questions
How does OpenSuperWhisper ensure type safety when storing preferences?
It uses custom Swift property wrappers (@UserDefault and @OptionalUserDefault) that map specific Swift types to UserDefaults keys. This prevents type mismatches at runtime and eliminates the need for raw string keys scattered throughout the codebase, as all access flows through the typed properties of AppPreferences.shared.
Where does the app handle migration of old preference keys?
Migration occurs lazily inside AppPreferences.init() via the migrateOldPreferences() method. This approach ensures existing users retain their settings when the schema changes, running only once when the singleton first initializes rather than on every launch.
How do UI components know when a preference has changed?
UI components observe specific notifications broadcast via NotificationCenter using constants defined in OpenSuperWhisper/Utils/NotificationName+App.swift. For example, language changes trigger .appPreferencesLanguageChanged, allowing views to reload without the preference store knowing about specific UI implementation details.
Can the AppPreferences singleton be tested independently?
Yes. Tests can reset the singleton by clearing UserDefaults.standard for the app bundle identifier and then re-instantiating AppPreferences.shared. Since all preferences are value types with explicit defaults, the test environment remains deterministic and isolated from other test cases.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →