Architecture
How WolfWave is built. MVVM + service-oriented Swift. ScriptingBridge → Apple Music, EventSub WebSocket → Twitch, IPC → Discord, WebSocket → overlay.
WolfWave follows a clean architecture with clear separation of concerns. Pattern: MVVM + Service-Oriented, with an NSApplicationDelegateAdaptor-based lifecycle.
Project Structure
The native app lives at apps/native/WolfWave/ with unit and integration tests at apps/native/WolfWaveTests/ and end-to-end UI tests at apps/native/WolfWaveUITests/. The top-level layout:
WolfWave/
├── WolfWaveApp.swift # @main + NSApplicationDelegateAdaptor
├── AppDelegate+*.swift # MenuBar / Services / Windows / DockMenu / StreamDeck splits
├── Core/ # Constants, Keychain, Logger, StreamerMode, …
├── Monitors/ # AppleMusicSource + playback update delegate
├── Services/
│ ├── Discord/ # DiscordRPCService. Local IPC socket
│ ├── ListeningHistory/ # Opt-in NDJSON play log + stats + monthly wrap
│ ├── Notifications/ # Opt-in song-change banner
│ ├── SongRequest/ # Queue, resolvers, AppleMusicController, blocklist, vote-skip
│ ├── Twitch/ # ChatService (EventSub), ChannelPointsService, DeviceAuth, Commands/
│ ├── UpdateChecker/ # SparkleUpdaterService
│ └── WebSocket/ # Token-gated WebSocketServerService, StreamDeckCommand, static WidgetHTTPService
├── Views/ # SwiftUI settings shell + per-section views + Onboarding wizard
└── Resources/ # widget.html, Assets.xcassets, dev-appcast.xmlEnumerating every file here would rot on each PR. For the full file-by-file breakdown, see the Source layout section of CLAUDE.md in the repo root.
Architecture Highlights
Delegation Pattern
PlaybackSourceDelegate is used for track update notifications, following Apple's delegation pattern. The delegate receives track name, artist, album, duration, and elapsed time on every update.
MVVM with @Observable
ViewModels separate UI logic from business logic. WolfWave uses the modern @Observable macro (migrated from ObservableObject / @Published):
TwitchViewModelmanages Twitch connection state.OnboardingViewModeldrives the first-run wizard.- Views observe
@Observableproperties directly. No@StateObjectwrapper required.
Modern Concurrency
Swift async/await throughout. No DispatchQueue for new async work.
Loose Coupling via NotificationCenter
Settings changes (e.g. TrackingSettingChanged, DockVisibilityChanged) flow through NotificationCenter. Names centralized in AppConstants.Notifications.
Thread Safety
TwitchChatService: actor isolation, with small lock-backed snapshots only for synchronous observation bridges.DiscordRPCService: serialipcQueueconfinement +enabledLock.Logger: serialDispatchQueuefor thread-safe file I/O.WebSocketServerService: actor-confined listener, connection, and playback state; anNSLockprotects only synchronous UI snapshots. Network.framework rejects messages over 16 KiB, and pre-handshake peers are capped and timed out before promotion.
Key Components
KeychainService
Secure storage for sensitive credentials using the macOS Keychain API. All tokens and secrets are stored securely, never in UserDefaults or plain text. Twitch access, refresh, username, user ID, and configured channel live in one versioned record, so an account or channel change is a single crash-atomic Keychain write. A channel edit is validated before it replaces the canonical record. Existing per-field records migrate copy-first and are deleted only after that write succeeds. A channel restored from backup stays a nonauthoritative UserDefaults hint until a successful OAuth commit. Keys are defined in AppConstants.Keychain.
AppleMusicSource
AppDelegate owns AppleMusicSource directly. It uses PID-targeted ScriptingBridge for Apple Music communication without spawning subprocesses or relaunching Music.app. It provides real-time track updates (including duration and elapsed time) through PlaybackSourceDelegate, distributed notifications, monotonic event deduplication, and a 5-second fallback poll that slows in low-power mode.
TwitchChatService
Full Twitch integration using:
- Helix API for sending messages and API requests.
- EventSub WebSocket for real-time chat message notifications.
- OAuth Device Code Flow for secure authentication.
- Network path monitoring for automatic reconnection.
- Durable paid-event intake and resolution via one atomic disk-backed outbox. Channel-point intake is saved before song lookup or queue work, then atomically replaced with the known fulfill/refund result before Helix delivery. Qualifying Bits cheers persist the complete replayable boost/request action before touching the in-memory queue, and atomically become a short-lived duplicate tombstone after completion. Relaunch refunds an unknown points intake conservatively and replays known point outcomes or pending Bits actions. Payment-bound processing survives socket reconnects while chat replies remain generation-scoped. A failed storage preflight pauses the managed reward; an already-paid Bits event falls back to process-owned execution and surfaces the same storage warning.
SongRequestService
Coordinates chat song requests end-to-end:
- Queue with hold mode and buffering when Music.app is closed.
- Resolvers:
SongSearchResolver(MusicKit) andLinkResolverService(native URL detection, Spotify/YouTube oEmbed, and validated Apple Music URLs). - Playback:
AppleMusicControllerplays tracks via AppleScript while preserving window focus. - Blocklist:
SongBlocklistblocks by track ID, artist, or album. - Approval screening: opt-in "Require My Approval" (
isApprovalRequired,approve/decline) holds each request in a pending state until the broadcaster acts, across chat, channel points, and bits.
DiscordRPCService
Discord Rich Presence integration using:
- Local IPC Socket: Unix domain socket (
discord-ipc-{0..9}). No bot token or server required. - iTunes Search API: Album artwork fetched dynamically with in-memory caching.
- Playback Progress: Elapsed time and duration shown as a progress bar.
- Auto-reconnect: Detects Discord availability and reconnects on its own.
- Sandbox Compatible: Uses SBPL entitlements for socket access within the App Sandbox.
Payload construction is pure and lives in DiscordPresenceBuilder; the IPC framing sits in DiscordRPCService+IPC.
Paused-state handling
Discord has no native "paused" activity flag, so WolfWave fakes one with two changes to the presence payload when Music reports paused:
- The
timestampsblock is omitted. Withoutstart/end, the Discord client stops the progress ticker instead of marching past the real elapsed value. assets.small_imageswaps fromapple_musictopause, andassets.small_textbecomes"Paused".
Track text, large image, and buttons stay unchanged so chat can still read what's loaded.
Rich Presence art assets (required upload)
small_image / large_image reference asset names registered against the Discord application, not bundled files. The PNGs live in discord-assets/:
| Asset name | Used when |
|---|---|
apple_music | Default large_image fallback + small_image "source" badge while playing |
pause | small_image badge that replaces apple_music while paused |
Fork maintainers shipping their own DISCORD_CLIENT_ID must upload both assets on the Discord application's Rich Presence → Art Assets page, or presence won't render correctly. See discord-assets/README.md.
WebSocketServerService
Local WebSocket server for OBS stream overlays using:
- Network.framework
NWListener: Native WebSocket server with auto-ping and multi-client support. - JSON Broadcasting: Sends
welcome,now_playing,progress,playback_state,overlay_visibility,widget_config, and (for the Stream Deck plugin)queue_state/healthmessages. - Loopback-only control channel: Parses inbound
commandframes only from awolfwave.control.<hex>client on a literal loopback IP, via an injectedonCommandhandler, and replies with anack. Envelope decode is pure (StreamDeckControl.parseinServices/WebSocket/StreamDeckCommand.swift); the command router lives inAppDelegate+StreamDeck. Full protocol on the Stream Deck page. - Progress Timer: 1-second interval broadcasts elapsed-time estimation to avoid polling ScriptingBridge.
- Auto-retry: Reconnects the listener after failures with configurable delay.
- Role-authenticated: Binds to all interfaces on the configured WebSocket port (
:8765by default);wolfwave.overlay.<hex>clients may receive state from LAN or loopback, while the separatewolfwave.control.<hex>role is accepted only from loopback and alone authorizes commands — see Security.
Owns a WidgetHTTPService instance that starts and stops alongside it.
WidgetHTTPService
Tiny companion HTTP server that serves the bundled widget.html to OBS:
- LAN-reachable: Binds to all interfaces on
:8766so remote browsers can pull the widget HTML. The static HTTP shell is not authenticated and injects only the read-only overlay token for loopback peers; bootstrap rules are covered in Security. GET /→200 OKwithwidget.htmlbytes from the app bundle.- Generated assets:
/widget-tokens.generated.js,/favicon.ico, and/favicon.pngare also served. - All other paths →
404 Not Found. - Lifecycle: Owned by
WebSocketServerService; starts/stops with the WS server.
The bundled widget.html is a generated artifact. Its source lives in
the apps/widget/ workspace (Tailwind + TypeScript), and the build pipeline
inlines the compiled CSS, design tokens, and JS runtime into a single
self-contained HTML file. The committed widget.html ships as-is (Xcode does
not rebuild it); CI rebuilds it before xcodebuild, and test CI fails the PR on
drift between the apps/widget/ sources and committed output. See the
OBS Widget Architecture page for the full pipeline,
message contract, theme/layout system, and transition state machine.
Stream Deck plugin (apps/streamdeck/)
The Elgato plugin that consumes the control channel above. A separate bun
workspace, bundled for Node (Stream Deck runs CodePath under its own bundled
Node, not Bun) into com.mrdemonwolf.wolfwave.sdPlugin/bin/plugin.js. Unlike
widget.html, this output is not committed and is not shipped inside the
app — the plugin is distributed through Elgato, not through WolfWave.
The client URL is fixed to 127.0.0.1; remote command hosts are intentionally
unsupported.
src/wolfwave/protocol.ts— pure wire layer and the TypeScript mirror ofStreamDeckCommand.swift. ItsACTIONSlist is the Swift enum's raw values andPROTOCOL_VERSIONmust equalStreamDeckControl.protocolVersion; a test pins the full v2 set so the two can't drift silently.src/wolfwave/state.ts— pure reducer folding inbound frames into the state keys render from. Returns the same object reference when nothing changed, so per-secondprogressframes don't repaint every key.src/wolfwave/client.ts— one shared socket for all keys (the app broadcastsqueue_state/healthto every client, so a socket per key would just multiply fan-out), with capped reconnect backoff and FIFO ack correlation.src/actions/— one class per manifest action over a shared base that paints the disconnected / unauthorized / outdated states centrally.
See the Stream Deck page for setup.
SparkleUpdaterService
Wraps the Sparkle framework for auto-updates:
- EdDSA-signed appcast verified against the public key in
Info.plist(SUPublicEDKey). - Release builds poll the remote
SUFeedURL. - DEBUG builds disable automatic checks; manual "Check Now" reads the bundled
dev-appcast.xml. - Homebrew installs disable Sparkle entirely (updates handled by Homebrew).
BotCommandDispatcher
Extensible command routing system that:
- Registers available commands at startup (
registerDefaultCommands()). - Merges streamer-authored
CustomBotCommands fromCustomCommandStoreon top of the built-ins, rebuilt per message so edits apply on the next chat line. - Matches incoming messages to trigger sets (commands implement
BotCommandorAsyncBotCommand). - Enforces global + per-user cooldowns via
CooldownManager(mods bypass). - Returns responses capped at 500 chars, with execution target under 100 ms.
Development
Build WolfWave from source and contribute. Xcode 16, macOS 26, Config.xcconfig setup, make targets, the test suite, notarization, and the release pipeline.
OBS Widget
How WolfWave's now-playing OBS overlay is built. A Tailwind + TypeScript workspace that compiles into a single self-contained widget.html with smooth play/stop transitions.