Development
Build WolfWave from source and send a pull request. Prerequisites, Config.xcconfig, make targets, tests, lint gates, release builds, and CI.
TL;DR: clone the repo, copy Config.xcconfig, run make build and make test, then open a PR.
This page is for contributors. Just want to use the app? See Installation.
The path
- Check the prerequisites.
- Clone the repo.
- Add your API keys in
Config.xcconfig. - Build and run the Debug app.
- Run the tests and the lint gates.
- Open a pull request.
Prerequisites
- macOS 26.0+ (Tahoe) on Apple Silicon (M1 or later).
- Xcode 26+, with Command Line Tools (
xcode-select --install). - Docker Desktop, only for
make lintandmake lint-crash-safety(they run a pinned SwiftLint image). - bun 1.3.11, only if you touch the docs site, OBS widget, Stream Deck plugin, or design tokens.
- Use exactly the version in the root
package.jsonpackageManagerfield. - Other bun releases minify the widget differently, so CI's drift check fails.
- Use exactly the version in the root
Only editing Swift? You don't need bun. Xcode ships the committed widget.html as-is.
Clone the repo
git clone https://github.com/MrDemonWolf/wolfwave.git
cd wolfwaveTouching web code too? Install the workspaces with the pinned bun:
bunx bun@1.3.11 install --frozen-lockfile--frozen-lockfile keeps bun.lock unchanged. Don't commit lockfile churn.
Configure API keys
Copy the example config:
cp apps/native/WolfWave/Config.xcconfig.example apps/native/WolfWave/Config.xcconfigThen fill in the two required keys:
TWITCH_CLIENT_ID = your_twitch_client_id_here
DISCORD_CLIENT_ID = your_discord_application_id_here- Twitch Client ID: create an app at dev.twitch.tv/console/apps.
- Discord Application ID: create an app at discord.com/developers/applications.
- Discord art assets: upload
apple_musicandpauseto your Discord app. See Rich Presence art assets.
Config.xcconfig is gitignored. Every contributor uses their own. Never commit it.
Optional keys
| Key | What it sets | Default when blank |
|---|---|---|
GITHUB_REPO_OWNER / GITHUB_REPO_NAME | Repo used for update checks and issue links | Upstream repo |
DOCS_URL | Docs site root (privacy, terms, changelog links derive from it) | Upstream docs |
COMMUNITY_DISCORD_URL | Tray menu Community Discord link | Upstream invite |
COPYRIGHT_HOLDER | Legal name in About and Monthly Wrap footers | MrDemonWolf, Inc. |
Escape // in URL values with $(), like DOCS_URL = https:/$()/example.com.
xcconfig reads a bare // as a comment and silently cuts the value to https:.
Where these values come from, in priority order:
- Your
Config.xcconfig. Local builds only. - CI. Release and test workflows write the keys into a generated
Config.xcconfig. Core/AppConstants. The fallback when a key is blank.
To rename the app itself, set PRODUCT_NAME and INFOPLIST_KEY_CFBundleDisplayName in the build configuration.
Keep the Debug and Release names different.
Build and run
make open-xcodePress ⌘R in Xcode. Or build from the terminal with make build.
- Debug builds are a separate app: WolfWave Dev (
com.mrdemonwolf.wolfwave.dev), with aDEVbadge on its icon. - It lives beside a real WolfWave install without touching its settings or Keychain.
make clean-devresets WolfWave Dev to a fresh first run. It never touches the production app.
Make targets
| Command | What it does |
|---|---|
make build | Debug build |
make clean | Clean build artifacts |
make clean-dev | Quit WolfWave Dev, wipe its settings and Keychain entry, clean builds |
make test | Unit tests (XCTest + Swift Testing) |
make test-verbose | Unit tests with full xcodebuild output |
make test-ui | XCUITest suite (launches the real app) |
make test-ci | Unit tests the way CI runs them, writes TestResults.xcresult |
make ci | Alias for make test-ci |
make lint | SwiftLint against the tracked baseline (needs Docker) |
make lint-baseline | Regenerate the baseline. It may only shrink |
make lint-crash-safety | No new force unwrap, try!, or as! (needs Docker) |
make lint-headers | Swift file-header convention |
make lint-sync | Source-derived lists, component catalog, docs values (incl. ports), lint claims, release-notes and version script tests |
make widget | Rebuild the OBS widget into Resources/widget.html |
make icons | Regenerate the Debug app icon from AppIcon.icon |
make sponsor-config | Regenerate SponsorConfig.generated.swift from FUNDING.yml |
make check-drift | Regenerate widget, tokens, SponsorConfig, and Debug icon; fail on drift |
make update-deps | Resolve SwiftPM dependencies |
make open-xcode | Open the Xcode project |
make prod-build | Release build + DMG in builds/ |
make prod-install | Release build + install to /Applications |
make notarize | Sign, notarize, and staple the DMG (needs Developer ID) |
make verify-notarize | Check the DMG's stapled ticket and Gatekeeper acceptance |
make build, every make test* target, and make prod-build run sponsor-config first, so you rarely call it yourself.
Testing
Run make test (or ⌘U in Xcode). It prints the file and pass count.
- Unit tests live in
apps/native/WolfWaveTests/. - New test file? Drop a
.swiftfile in that folder and@testable import WolfWave. No Xcode project edit needed. - Your real data is safe. Tests use a throwaway settings suite and an in-memory Keychain. Never use
UserDefaults.standardin a test. - Isolated output.
make test*builds into the gitignoredDerivedData/Tests, so the unsigned test host never replaces your signed Debug app.
The rules for shared test state (the SharedTestStateIsolation lock, WolfWaveTestCase) are in the repo's CLAUDE.md.
UI tests
make test-ui runs WolfWaveUITests. It launches the real app and drives it:
- the onboarding wizard, end to end;
- every settings pane, with a screenshot of each attached to the result.
The app runs with WOLFWAVE_UI_TEST=1, which swaps in throwaway settings and Keychain storage and keeps Apple Music, Twitch, Discord, and Sparkle off.
UI tests run as their own CI job and never gate a release.
Details: apps/native/docs/end-to-end-testing.md.
Web workspace tests
bun run --filter widget test # OBS widget (bun test + happy-dom)
bun run --filter streamdeck test # Stream Deck plugin
bun run ds:test # design-system lint rulesLint gates
Run these before you push. CI runs the same ones.
make lint # SwiftLint against the tracked baseline
make lint-crash-safety # Blocking: no new force unwrap, try!, or as!
make lint-headers # Blocking: Swift file headers match the Xcode template
make lint-sync # Blocking: source, catalog, and docs stay in sync
bun run ds:lint # Blocking: no literal font sizes, spacing, padding, or radius in views
bun run ds:schema # Validate tokens.json against its schemaGenerated files
Some committed files are generated. Never edit them by hand.
| Generated file | Source | Regenerate with |
|---|---|---|
Resources/widget.html | apps/widget/ | make widget |
Tokens.generated.swift, tokens.generated.css, widget-tokens.generated.js, widget-themes.generated.ts | design-system/tokens.json | bun run tokens |
Resources/AppIcon-Dev.icon | Resources/AppIcon.icon | make icons |
Core/SponsorConfig.generated.swift | .github/FUNDING.yml | make sponsor-config |
Commit the regenerated output with your source change. make check-drift runs CI's drift check locally.
The widget pipeline is covered on the OBS Widget page.
Open a pull request
- Branch off
main. One concern per PR. - Run
make testand the lint gates. - Fill in the PR template checklists.
- User-facing change? Add a one-line bullet to
CHANGELOG.mdunder the top## [x.y.z]heading, and to the top block ofchangelog.mdx. - Contributor-only change? Put it under
### DeveloperinCHANGELOG.md. It skips the in-app notes and the docs changelog. - Update the docs your change touches (
features,settings,bot-commands,architecture). - Attach a screenshot or recording for any UI change.
Don't bump CURRENT_PROJECT_VERSION. CI sets the build number from scripts/version.sh.
More detail: CONTRIBUTING.md and CLAUDE.md.
Rich Presence art assets (fork maintainers)
Rich Presence shows two images registered on the Discord app. They are not bundled with WolfWave.
| Asset name | Shown when |
|---|---|
apple_music | Fallback large image, and the small badge while playing |
pause | Small badge while paused |
Shipping a fork with your own DISCORD_CLIENT_ID?
- Open your Discord app's Rich Presence → Art Assets page.
- Upload both PNGs from
discord-assets/with those exact lowercase names. - Wait about 10 minutes for Discord's CDN.
Production build
make prod-build- The DMG lands at
builds/WolfWave-{version}.dmg. - With a Developer ID Application certificate in your Keychain, the app is re-signed for distribution (inside-out, via
scripts/codesign-app.sh, the same script CI uses). make prod-installbuilds and copies the app to/Applications.- The DMG background comes from
scripts/generate-dmg-background.swift. Regenerate withswift scripts/generate-dmg-background.swift assetsand commit the PNGs.
Notarization
APPLE_ID=you@example.com \
APPLE_TEAM_ID=YOUR_TEAM_ID \
APPLE_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx \
make notarizemake notarizesigns, submits, and staples the DMG.- It then runs
stapler validateandspctl --assess, so a bad ticket fails here, not on a user's Mac. make verify-notarizereruns those two checks on an existing DMG.
Create an app-specific password at appleid.apple.com under Sign-In and Security → App-Specific Passwords.
CI/CD
| Workflow | When it runs | What it does |
|---|---|---|
test.yml (CI) | Every push and PR to main | Unit tests, drift checks, widget tests, UI tests, docs build, Stream Deck plugin, SwiftLint, crash-safety, headers, sync, design-system lint. Each job runs only when its files change |
build_release.yml | v* tag push | Build, sign, notarize, embed release notes in the Sparkle appcast, upload the DMG to a draft GitHub Release |
update_homebrew.yml | After a release is published | Opens a PR bumping the Homebrew cask |
docs.yml | Docs changes, or a published stable release | Deploys this site to GitHub Pages |
nightly.yml | Daily at 05:00 UTC | Signed build off main for the Nightly channel. Skipped when nothing that ships changed |
dependabot.yml | Weekly | Grouped updates for GitHub Actions, SwiftPM, and bun |
Required GitHub secrets
| Secret | What it is |
|---|---|
TWITCH_CLIENT_ID | Twitch application Client ID |
DISCORD_CLIENT_ID | Discord application ID |
DEVELOPER_ID_CERT_P12 | Base64 Developer ID certificate |
DEVELOPER_ID_CERT_PASSWORD | Password for that certificate |
APPLE_ID | Apple ID for notarization |
APPLE_TEAM_ID | Apple Developer Team ID |
APPLE_APP_PASSWORD | App-specific password for notarization |
SPARKLE_PRIVATE_KEY | EdDSA key that signs the appcast |
PROVISIONING_PROFILE_B64 | Optional. Developer ID profile with iCloud, for "Sync with iCloud" |
Cutting a release
Push a tag that matches MARKETING_VERSION:
git tag vX.Y.Z
git push origin vX.Y.Z- The workflow uploads to a draft release. Rerunning updates the draft; it never replaces a published release.
- Review the draft and publish it.
- The Homebrew cask and the docs site update on their own.
The full list is the Release Checklist.
Next steps
WolfWave vs. Jam Deck, Cider, Now Playing & More
How WolfWave compares to Jam Deck, Cider, Now Playing, and Discord presence apps for Apple Music streamers. What each does, and where WolfWave falls short.
Architecture
How WolfWave is built. MVVM plus services in Swift 6. ScriptingBridge reads Apple Music, then Twitch EventSub, Discord IPC, and a local WebSocket fan it out.