Skip to content
WolfWave
Under the hood

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

Enumerating 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):

  • TwitchViewModel manages Twitch connection state.
  • OnboardingViewModel drives the first-run wizard.
  • Views observe @Observable properties directly. No @StateObject wrapper 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: serial ipcQueue confinement + enabledLock.
  • Logger: serial DispatchQueue for thread-safe file I/O.
  • WebSocketServerService: actor-confined listener, connection, and playback state; an NSLock protects 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) and LinkResolverService (native URL detection, Spotify/YouTube oEmbed, and validated Apple Music URLs).
  • Playback: AppleMusicController plays tracks via AppleScript while preserving window focus.
  • Blocklist: SongBlocklist blocks 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:

  1. The timestamps block is omitted. Without start/end, the Discord client stops the progress ticker instead of marching past the real elapsed value.
  2. assets.small_image swaps from apple_music to pause, and assets.small_text becomes "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 nameUsed when
apple_musicDefault large_image fallback + small_image "source" badge while playing
pausesmall_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 / health messages.
  • Loopback-only control channel: Parses inbound command frames only from a wolfwave.control.<hex> client on a literal loopback IP, via an injected onCommand handler, and replies with an ack. Envelope decode is pure (StreamDeckControl.parse in Services/WebSocket/StreamDeckCommand.swift); the command router lives in AppDelegate+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 (:8765 by default); wolfwave.overlay.<hex> clients may receive state from LAN or loopback, while the separate wolfwave.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 :8766 so 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 OK with widget.html bytes from the app bundle.
  • Generated assets: /widget-tokens.generated.js, /favicon.ico, and /favicon.png are also served.
  • All other paths404 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 of StreamDeckCommand.swift. Its ACTIONS list is the Swift enum's raw values and PROTOCOL_VERSION must equal StreamDeckControl.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-second progress frames don't repaint every key.
  • src/wolfwave/client.ts — one shared socket for all keys (the app broadcasts queue_state / health to 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 from CustomCommandStore on top of the built-ins, rebuilt per message so edits apply on the next chat line.
  • Matches incoming messages to trigger sets (commands implement BotCommand or AsyncBotCommand).
  • Enforces global + per-user cooldowns via CooldownManager (mods bypass).
  • Returns responses capped at 500 chars, with execution target under 100 ms.

On this page