Skip to content

Add Twitch widget with Google Fonts and appearance controls#7

Merged
nathanialhenniges merged 15 commits intomainfrom
dev
Feb 20, 2026
Merged

Add Twitch widget with Google Fonts and appearance controls#7
nathanialhenniges merged 15 commits intomainfrom
dev

Conversation

@nathanialhenniges
Copy link
Copy Markdown
Member

@nathanialhenniges nathanialhenniges commented Feb 20, 2026

Summary

  • Added OBS widget overlay with WebSocket server broadcasting now-playing data
  • Expanded widget font picker to 16 options (4 built-in + 12 Google Fonts) with sectioned picker UI
  • All Google Fonts preloaded in a single CDN request on widget mount for instant switching with browser caching
  • Added "Open" button next to "Copy URL" to preview the widget in the browser
  • Widget auto-hides when WebSocket disconnects and reappears on reconnect
  • Fixed actor isolation warnings in Logger and TwitchChatService
  • Added PowerStateMonitor, update checker, unit tests, CI/CD pipeline, and docs

Test plan

  • Build passes (make build) with zero warnings
  • All tests pass (make test)
  • Settings → OBS Widget → Font picker shows "Built-in" and "Google Fonts" sections
  • Select a Google Font (e.g. "Montserrat") → widget renders in that font
  • "Open" button opens widget URL in default browser
  • Stop WebSocket server → widget hides; restart → widget reappears on playback

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • OBS Stream Widget for customizable browser-source stream overlays with multiple themes, layouts, text/background colors, and font options
    • Twitch bot command cooldown controls (global and per-user)
    • Discord connection testing capability
    • Log export for diagnostics
  • Improvements

    • Enhanced network resilience with reconnection rate limiting and automatic message retries
    • System power-state awareness for optimized monitoring during reduced-power mode
  • Documentation

    • Comprehensive setup guides for OBS widget integration and updated feature documentation

nathanialhenniges and others added 14 commits February 8, 2026 21:03
- Update CLAUDE.md with Discord service, CI/CD, docs site, and current stack
- Update README description and dark/light mode logo
- Add Discord Rich Presence to homepage feature grid
- Fix Discord support link to mrdwolf.net
- Split docs sidebar into Guide and Developers sections
Implement a local WebSocket server (Network.framework NWListener) that
broadcasts now-playing JSON to connected clients, and a React widget
page on the docs site for use as an OBS browser source overlay.

Swift:
- WebSocketServerService with multi-client support, progress timer,
  and auto-retry on failure
- Functional WebSocket settings view replacing WIP placeholder
- AppDelegate wiring for music playback and artwork URL forwarding
- Network server entitlement for App Sandbox
- Missing AppConstants (Update, URLs, notifications) that blocked build
- MusicPlaybackMonitor delegate updated to pass duration/elapsed

Docs:
- /widget page with WebSocket client, album art blur background,
  progress bar with requestAnimationFrame interpolation, auto-reconnect
- Supports port, duration (auto-hide), and hideAlbumArt URL params

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Rename sidebar label from "WebSocket" to "OBS Widget" in settings
- Fix App Visibility card to be full width in detail pane
- Add Discord and OBS Widget steps to onboarding wizard (4 steps)
- Update welcome step with Discord/OBS feature highlights
- Update README, features, usage, installation, development, and
  architecture docs to reflect OBS Widget naming and new onboarding
- Fix pre-existing TwitchViewModelTests referencing missing
  ChannelValidationState enum
- Fix double notification in WolfWaveApp WebSocket state callback
- Update onboarding tests for 4-step navigation (142 tests pass)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Streamline doc comments across all files to be concise and professional,
removing verbose multi-paragraph descriptions and redundant inline
comments while keeping MARK headers. Add a WolfWave icon placeholder
in the stream overlay widget when album artwork hasn't loaded yet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Wrap setReauthNeeded call in MainActor.run to eliminate the
'no async operations occur within await' compiler warning.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Resolve all outstanding compliance items: code signing, notarization,
and credential security now pass. Add WW-17 WebSocket entitlements
and Network.framework dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove DispatchQueue.main.asyncAfter delay before opening settings on
token expiration — the delayed dispatch could race with notification
tap activation, causing duplicate window creation and a crash.

Replace the disabled "Connect" button with an orange "Re-auth" button
when reauthNeeded is true, giving users a clear action to re-authorize
their expired Twitch session.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move Music Monitor and App Visibility into Advanced, rename Discord
to Discord Integration, and fix locale-aware port number formatting
(8,765 → 8765) by using Text(verbatim:).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Return false from applicationShouldHandleReopen to prevent AppKit's
default window restoration from conflicting with manual openSettings().
Clear settingsWindow reference on close so restoreMenuOnlyIfNeeded
correctly detects no visible windows remain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Bug fixes:
- Fix force unwrap crash in DiscordRPCService socket handling
- Fix unlimited reconnect loop in TwitchChatService network monitor
- Add retry mechanism with exponential backoff for Twitch chat messages
- Fix notification observer leak in TwitchViewModel
- Replace debug print() calls with Log utility

Features:
- Add bot command cooldowns with global/per-user limits and mod bypass
- Add in-app update banner in settings
- Add log file writing and export button in Advanced settings
- Add Apple Music permission detection and guidance
- Add Copy Widget URL menu item to status bar
- Add widget customization (theme, layout, colors, font) with WebSocket broadcast
- Add Test Connection buttons for Twitch (token validation) and Discord (IPC check)

Code quality:
- Extract hardcoded values to AppConstants (Twitch, Widget, WebSocket)
- Unify duplicate artwork fetching into shared ArtworkService singleton
- Add Color(hex:) extension for hex color parsing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Expand the widget font picker from 4 built-in options to 16 fonts
(4 built-in + 12 Google Fonts) with sectioned picker UI. All Google
Fonts are preloaded in a single request on widget mount for instant
switching. Also adds an "Open in browser" button for the widget URL,
hides the widget when WebSocket disconnects, and fixes actor isolation
warnings in Logger.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 20, 2026

Warning

Rate limit exceeded

@nathanialhenniges has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 16 minutes and 33 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

Walkthrough

This PR introduces an OBS Stream Widget feature that broadcasts now-playing music data via a local WebSocket server. It includes a web-based widget renderer in Next.js, power state monitoring for efficiency, comprehensive logging with file export, Twitch bot command cooldown management, and artwork fetching. Documentation and onboarding flows are updated to reflect the new widget-centric approach.

Changes

Cohort / File(s) Summary
Project Configuration
.gitignore, src/wolfwave/wolfwave.dev.entitlements, src/wolfwave/wolfwave.entitlements, src/wolfwave.xcodeproj/project.pbxproj
Updates ignore patterns, adds network server sandbox entitlement for WebSocket support, and replaces Unicode escape sequences with direct copyright character.
Documentation & Compliance
COMPLIANCE.md, README.md, docs/content/docs/...
New compliance documentation detailing code signing and notarization; README and feature docs reworked to replace WebSocket Streaming narrative with OBS Browser Source Widget guidance; installation and usage sections updated with widget setup instructions.
OBS Widget Frontend
docs/app/widget/layout.tsx, docs/app/widget/page.tsx, docs/app/widget/themes.ts
New Next.js widget application with metadata, client-side WebSocket connection, theme system with presets (Default, Dark, Light, Glass, Neon, Techy), layout modes (Horizontal, Vertical, Compact), Google Font support, and real-time playback UI rendering.
Core Infrastructure
src/wolfwave/Core/AppConstants.swift, src/wolfwave/Core/Logger.swift, src/wolfwave/Core/PowerStateMonitor.swift
Expanded AppConstants with WebSocketServer, Widget, PowerManagement, Update, and URLs namespaces; Logger enhanced with rotating file logging, export capability, and debug build log enablement; new PowerStateMonitor singleton tracks system power/thermal state changes.
WebSocket Server Service
src/wolfwave/Services/WebSocket/WebSocketServerService.swift
New Network.framework-based WebSocket server broadcasting now-playing metadata, progress updates, and widget configuration to overlay clients; manages connections, handles reconnects, and supports port/state/interval updates.
Artwork Service
src/wolfwave/Services/ArtworkService.swift
New singleton fetching album artwork from iTunes Search API with in-memory caching, URL upscaling to 512x512, and thread-safe concurrent access.
Discord Service Updates
src/wolfwave/Services/Discord/DiscordRPCService.swift
Replaced internal artwork cache with ArtworkService delegation, added onArtworkResolved callback for async artwork notifications, introduced testConnection() for IPC validation, and added updatePollInterval() for runtime polling adjustment.
Twitch Command System
src/wolfwave/Services/Twitch/Commands/BotCommand.swift, src/wolfwave/Services/Twitch/Commands/BotCommandDispatcher.swift, src/wolfwave/Services/Twitch/Commands/CooldownManager.swift, src/wolfwave/Services/Twitch/Commands/SongCommand.swift, src/wolfwave/Services/Twitch/Commands/LastSongCommand.swift
Introduced CooldownManager for global/per-user cooldown tracking with moderator bypass; BotCommand protocol now requires globalCooldown and userCooldown properties; BotCommandDispatcher refactored to enforce cooldowns before execution and accept userID/isModerator params; Song/LastSong commands now use configurable message truncation constants.
Twitch Chat Service
src/wolfwave/Services/Twitch/TwitchChatService.swift
Added network resilience layer: WebSocket lock for thread-safe state, rate-limited reconnects with cooldown tracking, exponential backoff retry queue for pending messages, guarded credential handling, and enhanced error logging.
Music Playback Monitoring
src/wolfwave/Monitors/MusicPlaybackMonitor.swift
Added runtime interval adjustment via updateCheckInterval(); fallback timer now uses configurable currentCheckInterval; track parsing extended to extract duration and elapsed time fields.
Update Checker
src/wolfwave/Services/UpdateChecker/UpdateCheckerService.swift
Short-circuits network check when PowerStateMonitor detects reduced-power mode.
Onboarding Flow
src/wolfwave/Views/Onboarding/OnboardingViewModel.swift, src/wolfwave/Views/Onboarding/OnboardingOBSWidgetStepView.swift, src/wolfwave/Views/Onboarding/OnboardingDiscordStepView.swift, src/wolfwave/Views/Onboarding/OnboardingWelcomeStepView.swift, src/wolfwave/Views/Onboarding/OnboardingView.swift
Added obsWidget step to onboarding sequence; new OnboardingOBSWidgetStepView handles widget URL generation and copy; OnboardingDiscordStepView refactored to accept presenceEnabled binding; OnboardingView now manages AppStorage flags for Discord and WebSocket enablement; Welcome step UI simplified.
Settings Views
src/wolfwave/Views/SettingsView.swift, src/wolfwave/Views/WebSocket/WebSocketSettingsView.swift, src/wolfwave/Views/Discord/DiscordSettingsView.swift, src/wolfwave/Views/MusicMonitor/MusicMonitorSettingsView.swift, src/wolfwave/Views/Advanced/AdvancedSettingsView.swift, src/wolfwave/Views/AppVisibility/AppVisibilitySettingsView.swift
SettingsView renamed WebSocket section to OBS Widget and added Twitch bot cooldown sliders; WebSocketSettingsView completely refactored from URI-based to port-centric model with widget appearance theming; DiscordSettingsView added connection test UI; MusicMonitorSettingsView refactored with Apple Events permission checking; AdvancedSettingsView added log export capability; minor styling adjustments.
Shared Views & Utilities
src/wolfwave/Views/Shared/UpdateBannerView.swift, src/wolfwave/Views/Shared/ViewModifiers.swift, src/wolfwave/Views/Twitch/DeviceCodeView.swift, src/wolfwave/Views/Twitch/TwitchDeviceAuthDialog.swift, src/wolfwave/Views/Twitch/TwitchDeviceAuthWindowController.swift
New UpdateBannerView displays available app updates with download action; added Color.init(hex:) failable initializer; improved logging in preview code; TwitchDeviceAuthWindowController now properly breaks retain cycles on window close.
Twitch Settings & Auth
src/wolfwave/Views/Twitch/TwitchSettingsView.swift, src/wolfwave/Views/Twitch/TwitchViewModel.swift
TwitchSettingsView refactored to use SignedInView with onReauth and onTestConnection callbacks; TwitchViewModel converted to token-based NotificationCenter observers, added testConnection() for OAuth validation, and improved connection state handling.
App Initialization
src/wolfwave/WolfWaveApp.swift
Wired WebSocket server initialization and power state monitoring hooks; added artwork fetching flow for widgets; refactored dock visibility and window lifecycle management; added Copy Widget URL menu action; consolidated service setup into dedicated methods.
Tests
src/WolfWaveTests/AppConstantsTests.swift, src/WolfWaveTests/WebSocketServerServiceTests.swift, src/WolfWaveTests/OnboardingViewModelTests.swift, src/WolfWaveTests/TwitchViewModelTests.swift
New WebSocketServerServiceTests cover server state, port validation, constants, and initialization; AppConstantsTests extended with WebSocket constant validation; OnboardingViewModelTests updated for 4-step flow and OBS widget as final step; removed obsolete ChannelValidationState tests.

Sequence Diagram(s)

sequenceDiagram
    actor User as User
    participant OBS as OBS Studio
    participant Widget as OBS Widget<br/>(Browser Source)
    participant WS as WebSocket Server<br/>(WolfWave)
    participant Music as Music App
    
    User->>WolfWave: Enable OBS Widget
    activate WS
    WS->>WS: Start WebSocket Server<br/>(port 8765)
    deactivate WS
    
    User->>OBS: Add Browser Source<br/>(Widget URL)
    activate Widget
    Widget->>WS: Connect WebSocket
    activate WS
    WS->>Widget: Send Welcome Message
    deactivate WS
    
    Music->>WolfWave: Track Changed
    WolfWave->>WolfWave: Fetch Album Artwork<br/>(ArtworkService)
    WolfWave->>WS: Broadcast now_playing<br/>(track, artist, artwork)
    activate WS
    WS->>Widget: Send now_playing
    deactivate WS
    Widget->>Widget: Render Track Info<br/>& Album Art
    
    loop Every 1 Second
        WolfWave->>WS: Broadcast progress<br/>(elapsed time)
        activate WS
        WS->>Widget: Send progress
        deactivate WS
        Widget->>Widget: Update Progress Bar
    end
Loading
sequenceDiagram
    participant PowerMon as PowerStateMonitor
    participant MusicMon as MusicPlaybackMonitor
    participant TwitchChat as TwitchChatService
    participant System as System
    
    System->>PowerMon: Power State Changed<br/>(Low Power Mode)
    activate PowerMon
    PowerMon->>PowerMon: Update isReducedMode
    PowerMon->>PowerMon: Post powerStateChanged
    deactivate PowerMon
    
    PowerMon->>MusicMon: Notify Power State Change
    activate MusicMon
    MusicMon->>MusicMon: updateCheckInterval()<br/>(reduced interval)
    MusicMon->>MusicMon: Reschedule Timer
    deactivate MusicMon
    
    PowerMon->>TwitchChat: Notify Power State Change
    activate TwitchChat
    TwitchChat->>TwitchChat: Adjust Poll Interval<br/>(reduced)
    deactivate TwitchChat
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

  • PR #2 — Directly related: introduces the same WebSocketServerService, AppConstants.WebSocketServer keys, OBS widget onboarding/settings views, entitlements, and test coverage.
  • PR #4 — Code-level related: modifies MusicPlaybackMonitor and Twitch command dispatching systems that are also updated in this PR.
  • PR #3 — Code-level related: both PRs expand src/wolfwave/Core/AppConstants.swift with new namespaces (WebSocketServer, Widget, PowerManagement, URLs).

Poem

🐰 A widget springs to life, broadcasting tunes so bright,
Through WebSocket streams and themed colors—what a sight!
OBS overlays now shimmer with album art in hand,
While cooldowns keep the chat bots civil, perfectly planned. ✨🎵

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 76.47% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects a major feature of the PR: adding an OBS widget with customizable appearance (Google Fonts, themes, colors) and controls, which are core changes throughout the implementation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch dev

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Resolve build errors from dev merge:
- Remove duplicate URLs and Update enums in AppConstants
- Add missing NSLock to BotCommandDispatcher
- Fix duplicate onReauth and missing channelValidationState in TwitchSettingsView
- Add missing state properties to AdvancedSettingsView (update checker)
- Add missing now-playing state and helpers to MusicMonitorSettingsView
- Fix hasClientID computed property assignment in DiscordSettingsView

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (8)
src/wolfwave/Views/Twitch/TwitchSettingsView.swift (1)

283-289: ⚠️ Potential issue | 🔴 Critical

onReauth is declared twice — build-breaking redeclaration confirmed by CI.

The property var onReauth: () -> Void exists at line 283 (pre-existing) and is re-declared at line 288 (new). Swift does not permit duplicate stored-property names; this is the exact error surfaced by the pipeline (invalid redeclaration of 'onReauth').

Remove the duplicate at line 288 and keep only the single declaration, then append onTestConnection after it:

🐛 Proposed fix
     var onReauth: () -> Void          // line 283 — keep this one
     var onClearCredentials: () -> Void
     var onJoinChannel: () -> Void
     var onLeaveChannel: () -> Void
     var onChannelIDChanged: () -> Void
-    var onReauth: () -> Void           // line 288 — remove duplicate
     var onTestConnection: () -> Void
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Views/Twitch/TwitchSettingsView.swift` around lines 283 - 289,
The property onReauth is declared twice causing a redeclaration error; remove
the duplicate declaration of onReauth (the second occurrence) in the
TwitchSettingsView where the properties are listed, keep the original onReauth
declaration, and ensure the following property list includes onTestConnection
right after onReauth so the final sequence contains a single onReauth and then
onTestConnection (symbols: onReauth, onTestConnection, onClearCredentials,
onJoinChannel, onLeaveChannel, onChannelIDChanged).
src/wolfwave/Views/Advanced/AdvancedSettingsView.swift (2)

11-28: ⚠️ Potential issue | 🟡 Minor

Stale DocC comment — still describes the removed software-update UI.

The doc comment on AdvancedSettingsView still lists "Checking for software updates (manual and automatic)" and "Software Update card shows current version…" as features, but that section was replaced by the Diagnostics / Music Monitor / App Visibility cards in this PR.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Views/Advanced/AdvancedSettingsView.swift` around lines 11 - 28,
The doc comment above AdvancedSettingsView is stale — it still documents the
removed software-update UI and related actions; update the top-level comment for
the AdvancedSettingsView type to reflect the current cards (Diagnostics, Music
Monitor, App Visibility) and current actions/state (bindings like
showingResetAlert, use of AppDelegate context, Reset Onboarding/Reset All
behavior) and remove references to software update checking, update
availability, and install instructions; edit the comment block that starts the
file (the triple-slash summary and bullet lists) so the description and bullet
points match the actual UI and methods in AdvancedSettingsView and its
resetSettings() usage.

193-199: ⚠️ Potential issue | 🔴 Critical

Remove or fix the onReceive handler and dead code — both reference undeclared variables.

The onReceive block (lines 193–199) assigns to latestVersion, updateAvailable, and skippedVersion, which have no @State declarations. The softwareUpdateCard @ViewBuilder (lines 204–340) references these same variables plus currentVersion, appDelegate, updateCheckEnabled, and isCheckingForUpdates—none of which are declared in this view. The openDownloadURL() function (lines 346–353) also references the undeclared appDelegate.

This causes immediate compilation failure. Either restore the missing property declarations or remove the notification handler and dead update-related code entirely.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Views/Advanced/AdvancedSettingsView.swift` around lines 193 -
199, The onReceive handler and related update UI/code reference undeclared state
and app delegate variables (latestVersion, updateAvailable, skippedVersion,
currentVersion, appDelegate, updateCheckEnabled, isCheckingForUpdates,
softwareUpdateCard, openDownloadURL) and must be removed or restored; to fix
quickly, delete the onReceive block and remove or comment out the
softwareUpdateCard ViewBuilder and the openDownloadURL() function (and any
references to the above variables) from AdvancedSettingsView so no undeclared
symbols remain, or alternatively reintroduce the missing
`@State/`@Environment/@ObservedObject properties with matching names and types and
update openDownloadURL() to use a valid app delegate reference if you prefer to
keep the update feature.
docs/content/docs/installation.mdx (1)

32-32: ⚠️ Potential issue | 🟡 Minor

"3-step guided setup" is now inaccurate — four steps are described.

With the new OBS Stream Widget step added after Discord (lines 69–79), the wizard now has four steps but the intro still says "a 3-step guided setup."

📝 Proposed fix
-On first launch, WolfWave walks you through a 3-step guided setup:
+On first launch, WolfWave walks you through a 4-step guided setup:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/content/docs/installation.mdx` at line 32, Update the introductory
sentence that currently reads "On first launch, WolfWave walks you through a
3-step guided setup:" to reflect the added OBS Stream Widget step by changing it
to "a 4-step guided setup:" (or "four-step guided setup:") so the intro matches
the four steps described later in the document; locate and edit the exact string
in the installation.mdx content where that sentence appears.
docs/content/docs/development.mdx (1)

29-29: ⚠️ Potential issue | 🟡 Minor

Stale test count in the command table.

The table on line 29 says "124 tests" but summing the Test Coverage table below (17+18+16+14+15+26+22+15) yields 143. Update the count to stay consistent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/content/docs/development.mdx` at line 29, The "make test" row in the
commands table shows a stale test count ("124 tests"); update that table entry
(the row containing `make test`) to reflect the correct total of 143 tests (sum
of the Test Coverage table: 17+18+16+14+15+26+22+15 = 143), and re-run or verify
test counts if tests have changed before committing to ensure the number stays
accurate.
src/wolfwave/Views/Discord/DiscordSettingsView.swift (1)

128-131: ⚠️ Potential issue | 🔴 Critical

Compile error: assigning to a computed property.

Line 129 assigns to hasClientID, but it was changed to a computed property (lines 36–38). This will fail to compile.

🐛 Fix: remove the assignment since it's now computed
     .onAppear {
-        hasClientID = DiscordRPCService.resolveClientID() != nil
         refreshConnectionState()
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Views/Discord/DiscordSettingsView.swift` around lines 128 - 131,
Remove the assignment to the computed property hasClientID inside the .onAppear
closure (the line calling DiscordRPCService.resolveClientID() and assigning to
hasClientID) since hasClientID is now a computed property; keep the
refreshConnectionState() call so the view still updates on appear. Locate the
.onAppear block in DiscordSettingsView where hasClientID is assigned and delete
that assignment, leaving only the call to refreshConnectionState().
src/wolfwave/Services/Twitch/TwitchChatService.swift (1)

890-911: ⚠️ Potential issue | 🟠 Major

Retry counter resets to zero on every send failure — retries are unbounded.

When sendMessage fails (line 908), it calls queueMessageForRetry(…, attempts: 0). However, retryPendingMessages (line 956) calls sendMessage which, on failure, re-queues at attempts: 0 again — discarding the accumulated attempt count from PendingMessage.attempts. This creates an infinite 1-second retry loop for persistently failing messages, never reaching maxMessageRetries.

Contrast with line 949–952 (the "still not connected" path), which correctly preserves message.attempts.

🐛 Proposed fix — propagate the attempt count through sendMessage

One approach: add an internal attempts parameter to the send path:

     func sendMessage(_ message: String, replyTo parentMessageID: String?) {
+        sendMessage(message, replyTo: parentMessageID, attempts: 0)
+    }
+
+    private func sendMessage(_ message: String, replyTo parentMessageID: String?, attempts: Int) {
         guard let broadcasterID = broadcasterID,
             let botID = botID,
             let token = oauthToken,
             let clientID = clientID
         else {
             Log.warn("Twitch: Not connected, queuing message for retry", category: "TwitchChat")
-            queueMessageForRetry(message: message, parentMessageID: parentMessageID, attempts: 0)
+            queueMessageForRetry(message: message, parentMessageID: parentMessageID, attempts: attempts)
             return
         }
         // ... truncation and body construction unchanged ...
 
         sendAPIRequest(
             // ... unchanged ...
         ) { [weak self] result in
             switch result {
             case .success(let data):
                 // ... unchanged ...
             case .failure(let error):
                 Log.error(
                     "Twitch: Failed to send message - \(error.localizedDescription)",
                     category: "TwitchChat")
-                self?.queueMessageForRetry(
-                    message: message, parentMessageID: parentMessageID, attempts: 0)
+                self?.queueMessageForRetry(
+                    message: message, parentMessageID: parentMessageID, attempts: attempts)
             }
         }
     }

Then in retryPendingMessages:

-        sendMessage(message.message, replyTo: message.parentMessageID)
+        sendMessage(message.message, replyTo: message.parentMessageID, attempts: message.attempts)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Services/Twitch/TwitchChatService.swift` around lines 890 - 911,
The retry logic resets attempts to zero on every send failure because
sendMessage calls queueMessageForRetry(message:parentMessageID:attempts: 0)
instead of preserving/incrementing the PendingMessage.attempts, so messages
never reach maxMessageRetries; fix by propagating and incrementing the attempt
count through the send path—either add an internal attempts parameter to
sendMessage or, in the failure branch of sendMessage, call queueMessageForRetry
with attempts: (message.attempts ?? 0) + 1 (or message.attempts + 1) so
retryPendingMessages and queueMessageForRetry use and respect
PendingMessage.attempts and maxMessageRetries consistently (update references in
sendMessage, retryPendingMessages, and queueMessageForRetry).
src/wolfwave/Views/SettingsView.swift (1)

58-73: ⚠️ Potential issue | 🟡 Minor

Add missing Discord sidebar icon.

Discord section has no sidebar icon. The code comment on line 62 states .discord: return nil // Uses custom image, indicating intent to use a custom icon. However, the customIcon property (lines 68–73) only includes a case for .twitchIntegration, leaving Discord with nil for both systemIcon and customIcon. The sidebarIcon view builder (lines 243–254) renders nothing when both are nil, resulting in an empty icon slot. The DiscordLogo asset already exists in the project and should be referenced.

Fix
     var customIcon: String? {
         switch self {
         case .twitchIntegration: return "TwitchLogo"
+        case .discord: return "DiscordLogo"
         default: return nil
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Views/SettingsView.swift` around lines 58 - 73, The Discord
sidebar icon is missing because customIcon only handles .twitchIntegration;
update the Custom icon provider by adding a case for .discord in the customIcon
computed property (alongside the existing .twitchIntegration case) to return the
existing "DiscordLogo" asset (leave systemIcon's .discord as nil); this ensures
the sidebarIcon view builder will render the DiscordLogo image when building the
sidebar.
🧹 Nitpick comments (31)
src/wolfwave/Views/Twitch/TwitchDeviceAuthDialog.swift (1)

149-156: Consider migrating DispatchQueue.main.asyncAfter to Task.sleep / async/await.

Both delayed callbacks use DispatchQueue.main.asyncAfter, which is inconsistent with the project's guideline to prefer async/await over DispatchQueue for async work. These are pre-existing but worth cleaning up opportunistically.

♻️ Suggested async/await replacements
 private func handleAuthorizePressed() {
     withAnimation(.easeInOut(duration: 0.3)) {
         isWaiting = true
     }
-    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
-        if let encodedCode = deviceCode.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
-           let url = URL(string: "https://www.twitch.tv/activate?device_code=\(encodedCode)") {
-            NSWorkspace.shared.open(url)
-        }
-        onAuthorizePressed()
-    }
+    Task { `@MainActor` in
+        try? await Task.sleep(for: .milliseconds(100))
+        if let encodedCode = deviceCode.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
+           let url = URL(string: "https://www.twitch.tv/activate?device_code=\(encodedCode)") {
+            NSWorkspace.shared.open(url)
+        }
+        onAuthorizePressed()
+    }
 }
 private func copyDeviceCode() {
     // …
-    DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
-        withAnimation(.easeInOut(duration: 0.2)) {
-            showCopyFeedback = false
-            isCodeCopied = false
-        }
-    }
+    Task { `@MainActor` in
+        try? await Task.sleep(for: .seconds(2))
+        withAnimation(.easeInOut(duration: 0.2)) {
+            showCopyFeedback = false
+            isCodeCopied = false
+        }
+    }
 }

As per coding guidelines: "Use async/await concurrency instead of DispatchQueue for new async work in Swift."

Also applies to: 171-176

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Views/Twitch/TwitchDeviceAuthDialog.swift` around lines 149 -
156, Replace the DispatchQueue.main.asyncAfter usages with Swift concurrency:
wrap the delayed work in a Task, mark UI work `@MainActor`, and use try await
Task.sleep(nanoseconds: 100_000_000) for the 0.1s delay; specifically replace
the block that uses DispatchQueue.main.asyncAfter around deviceCode,
NSWorkspace.shared.open(...) and onAuthorizePressed() with Task { `@MainActor` in
try await Task.sleep(nanoseconds: 100_000_000); if let encodedCode =
deviceCode.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let
url = URL(string: "https://www.twitch.tv/activate?device_code=\(encodedCode)") {
NSWorkspace.shared.open(url) } onAuthorizePressed() }, and make the same change
for the other occurrence that currently uses DispatchQueue.main.asyncAfter so
both follow async/await and run on the main actor.
src/wolfwave/Views/Shared/ViewModifiers.swift (1)

138-151: Move Color extension to a dedicated file

ViewModifiers.swift is scoped to ViewModifier-conforming types and their View helper extensions. The Color(hex:) initializer (lines 139–152) is a general utility extension that doesn't belong here. Create src/wolfwave/Views/Shared/Color+Hex.swift and move the extension there to maintain file cohesion, especially as color-customization features (e.g., alpha-channel support) expand.

♻️ Suggested file split

Create src/wolfwave/Views/Shared/Color+Hex.swift:

import SwiftUI

// MARK: - Color Hex Initializer

extension Color {
    /// Creates a Color from a hex string (e.g. "#FF0000" or "FF0000").
    init?(hex: String) {
        var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines)
        if hexString.hasPrefix("#") { hexString.removeFirst() }
        guard hexString.count == 6, let value = UInt64(hexString, radix: 16) else { return nil }
        let r = Double((value >> 16) & 0xFF) / 255.0
        let g = Double((value >> 8) & 0xFF) / 255.0
        let b = Double(value & 0xFF) / 255.0
        self.init(red: r, green: g, blue: b)
    }
}

Remove lines 139–152 from ViewModifiers.swift.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Views/Shared/ViewModifiers.swift` around lines 138 - 151, Move
the Color hex initializer into its own file: create a new Swift file named
Color+Hex.swift, import SwiftUI, and paste the extension containing init?(hex:)
(the Color extension/initializer shown) into that file; then remove the
init?(hex:) extension block from ViewModifiers.swift so ViewModifiers.swift only
contains ViewModifier-related code and the Color hex utility lives in
Color+Hex.swift for future expansion (e.g., alpha support).
src/wolfwave/Monitors/MusicPlaybackMonitor.swift (2)

93-105: Consider annotating MusicPlaybackMonitor (or updateCheckInterval) with @MainActor to encode the implicit thread contract.

The class is clearly designed for main-thread use — delegate callbacks are dispatched to DispatchQueue.main, DistributedNotificationCenter delivers on the main thread, and startTracking/stopTracking are expected to be called from UI code. updateCheckInterval mutates timer, isTracking, and currentCheckInterval without any synchronization. Today this is safe because all callers happen to be on the main thread, but a future caller dispatching from a background queue would introduce a data race with no compile-time warning.

Marking the class @MainActor (or at minimum annotating updateCheckInterval and the other lifecycle methods) would enforce this invariant at compile time.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Monitors/MusicPlaybackMonitor.swift` around lines 93 - 105,
MusicPlaybackMonitor mutates shared state (timer, isTracking,
currentCheckInterval) on the implicit assumption callers are on the main thread;
annotate the class or its lifecycle methods to enforce that contract. Add
`@MainActor` to the MusicPlaybackMonitor declaration (or at minimum to
updateCheckInterval, startTracking, stopTracking and any methods touching
timer/isTracking/currentCheckInterval like setupFallbackTimer) so the compiler
enforces main‑thread access and prevents data races.

154-154: Pre-existing delimiter injection risk is now more likely to produce silently wrong data rather than fail fast.

The guard was relaxed from == 3 to >= 3 so the 5-component format works, and the numeric fields (duration, elapsed) are safe since Double strings cannot contain |. However, if an artist or album name legitimately contains |, the old == 3 guard would have silently dropped the track; the new >= 3 guard lets it proceed with components[1]/[2] containing wrong data and Double(components[3]) returning nil → duration/elapsed both zero with no diagnostic.

This is a pre-existing design limitation (using a freeform delimiter in a combined string built from arbitrary user-supplied metadata), but worth addressing in a follow-up by switching to a safe serialisation — e.g. a null-byte joined string, or passing the five values as a small struct directly through handleTrackInfo to processTrackInfoString to avoid any delimiter parsing entirely.

Also applies to: 187-198

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Monitors/MusicPlaybackMonitor.swift` at line 154, The combined
string construction using the delimiter (variable combined) in
MusicPlaybackMonitor allows delimiter injection and the relaxed guard (>= 3)
causes silent misparses; update the code to stop using a freeform delimiter:
change handleTrackInfo/processTrackInfoString to pass a small struct (e.g.,
TrackInfo with name, artist, album, duration, elapsed) instead of a single
combined string, adjust callers to build and pass TrackInfo, and remove
delimiter splitting logic; if you must keep string parsing as a short-term fix,
enforce an exact component count (restore strict check for the expected number
of components), validate Double parsing for duration/elapsed with error logging
on failure, and return early on parse errors to avoid silent zero values.
src/wolfwave/Views/Twitch/TwitchDeviceAuthWindowController.swift (1)

89-89: @State is misused in a non-View struct

@State is designed exclusively for types conforming to SwiftUI.View. In a plain struct like TwitchDeviceAuthWindow, the heap-allocated backing storage happens to work as a side-effect of the implementation, but this is undocumented and not guaranteed across Swift/SwiftUI versions. It also makes the mutating keyword misleading (the setter goes through the reference-type backing store, so struct mutation is not actually required).

A plain optional property is correct, idiomatic, and equally effective here:

♻️ Proposed refactor
 struct TwitchDeviceAuthWindow {
     let deviceCode: String
     let onAuthorize: () -> Void
     let onCancel: () -> Void
 
-    `@State` private var retainedController: TwitchDeviceAuthWindowController?
+    private var retainedController: TwitchDeviceAuthWindowController?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Views/Twitch/TwitchDeviceAuthWindowController.swift` at line 89,
The property retainedController is incorrectly declared with `@State` inside the
plain struct TwitchDeviceAuthWindow; remove the `@State` wrapper and change it to
a normal optional property (e.g., private var retainedController:
TwitchDeviceAuthWindowController?) so the struct uses ordinary storage; update
any places that relied on SwiftUI state behavior (mutating setters or bindings)
to directly set the optional on the struct instance or make the setter mutating
if needed, and ensure TwitchDeviceAuthWindowController references remain
unchanged.
src/wolfwave/Views/MusicMonitor/MusicMonitorSettingsView.swift (1)

105-119: Combine the two .onAppear modifiers into one.

Having two .onAppear modifiers on the same VStack works (both fire), but it is fragile and non-idiomatic. Merge them into a single closure.

♻️ Proposed fix
-.onAppear {
-    loadCurrentTrack()
-}
 .onReceive(...) { ... }
+.onAppear {
+    loadCurrentTrack()
+    checkMusicPermission()
+}
-.onAppear {
-    checkMusicPermission()
-}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Views/MusicMonitor/MusicMonitorSettingsView.swift` around lines
105 - 119, There are two .onAppear modifiers; merge them into a single .onAppear
closure so both startup actions run from one place: call loadCurrentTrack() and
checkMusicPermission() in the same closure (preserve order), leaving the
existing .onReceive(NotificationCenter.default.publisher(...)) block intact;
update the VStack (or the view that currently has the two .onAppear modifiers)
to use one .onAppear that invokes loadCurrentTrack() and checkMusicPermission().
src/wolfwave/Views/Advanced/AdvancedSettingsView.swift (1)

147-150: Log Export card uses inline card styling instead of .cardStyle().

The Onboarding card directly above uses .cardStyle(), but the new Log Export card manually applies .padding(), .background(), and .clipShape() without an .overlay() stroke, resulting in a visually inconsistent card. Apply .cardStyle() for consistency (or align the inline styling to match the Danger Zone card pattern if a distinct look is intended).

♻️ Proposed fix
 }
-.padding(AppConstants.SettingsUI.cardPadding)
-.background(Color(nsColor: .controlBackgroundColor))
-.clipShape(RoundedRectangle(cornerRadius: AppConstants.SettingsUI.cardCornerRadius))
+.cardStyle()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Views/Advanced/AdvancedSettingsView.swift` around lines 147 -
150, The Log Export card currently applies inline styling (.padding,
.background, .clipShape) producing a visual mismatch with other cards; update
the view that builds the Log Export card to use the reusable .cardStyle()
modifier (or, if a distinct look is required, mirror the Danger Zone card by
adding the same .overlay(stroke) and corner radius used there) so its padding,
background, corner radius and stroke match the rest of the UI; locate the card
view in AdvancedSettingsView (the Log Export card block) and replace the inline
styling with a call to .cardStyle() or adjust the inline styling to exactly
match the Danger Zone card’s overlay/stroke and AppConstants.SettingsUI values.
docs/app/widget/page.tsx (3)

214-260: nowPlaying! non-null assertions are safe but fragile.

The render functions (renderHorizontal, renderVertical, renderCompact) use nowPlaying! extensively (e.g., lines 218, 220, 250, 301, 332, 381, 412). These are safe today because renderLayout() is only called inside {nowPlaying && (...)} on line 451, but a future refactor could easily break this guard.

Consider passing nowPlaying as a parameter so the type system enforces non-nullability:

Suggested approach
-  const renderHorizontal = () => (
+  const renderHorizontal = (np: NowPlayingData) => (
     <div className="relative flex h-full">
       {!hideAlbumArt && (
         <div className="flex-shrink-0 p-[5px]">
-          {nowPlaying!.artworkURL ? (
+          {np.artworkURL ? (

Then call as renderHorizontal(nowPlaying) from renderLayout, which itself takes the non-null value from the guard.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/app/widget/page.tsx` around lines 214 - 260, The render helpers use
fragile non-null assertions on nowPlaying; change renderHorizontal,
renderVertical, and renderCompact to accept a non-null nowPlaying parameter
(e.g., renderHorizontal(nowPlaying: NowPlayingType)) and update their references
to use the local parameter instead of nowPlaying!, then update renderLayout to
take the non-null nowPlaying from the existing guard and call the helpers as
renderHorizontal(nowPlaying) / renderVertical(nowPlaying) /
renderCompact(nowPlaying) so the type system enforces non-nullability.

44-46: status state is set but never read.

ConnectionStatus is tracked via setStatus on lines 161, 168, 179 but never referenced in render output or any conditional logic. This is dead state that adds unnecessary re-renders on every connect/disconnect cycle.

Either remove it or use it (e.g., to show a connection indicator).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/app/widget/page.tsx` around lines 44 - 46, The ConnectionStatus state
variable (status) is never read but is updated via setStatus in the component
(e.g., calls to setStatus when connection changes), causing unnecessary
re-renders; either remove the state and all setStatus calls (cleanup the
handlers that call setStatus) or actually use the state in render (for example
render a connection indicator using status) so it serves a purpose—locate the
useState declaration for status and the setStatus usages and either delete both
the state and its setters or add JSX/conditional logic that references status to
display connection state.

148-151: Silent catch on unknown message types.

The empty catch {} on line 151 swallows all JSON parse errors silently. For an OBS overlay this is arguably correct (don't show errors to viewers), but a console.debug for non-production troubleshooting would help widget developers diagnose issues.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/app/widget/page.tsx` around lines 148 - 151, The empty catch after the
switch (the block that follows case "welcome") is swallowing JSON parse errors;
update that catch to accept the error (e.g., catch (err)) and emit a
non-production debug log with the error and the raw message payload (use
console.debug or similar) so developers can troubleshoot — ensure the log only
fires in non-production environments or is gated by an env check to avoid
leaking to viewers.
src/wolfwave/Views/Onboarding/OnboardingView.swift (1)

14-14: Missing #Preview block.

The coding guidelines recommend including a #Preview block for SwiftUI views. A simple preview with a no-op closure would suffice:

`#Preview` {
    OnboardingView(onComplete: {})
}

As per coding guidelines: "When creating SwiftUI views, include @State properties for UI state, a body property with View components, and a #Preview block for previews"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Views/Onboarding/OnboardingView.swift` at line 14, Add a SwiftUI
preview for OnboardingView by adding a `#Preview` block that instantiates
OnboardingView with a no-op closure (e.g., OnboardingView(onComplete: {}));
place the block near the view declaration so Xcode previews compile and follow
the project's guideline to include a preview for the OnboardingView type.
src/wolfwave/Services/ArtworkService.swift (2)

81-82: Artwork URL upscaling via string replacement is fragile.

replacingOccurrences(of: "100x100", with: "512x512") silently produces the original 100×100 URL if Apple changes their URL format. This won't break anything (the URL still works), but the widget would get low-res artwork. Consider a regex or checking whether the replacement actually changed the string, and logging if it didn't.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Services/ArtworkService.swift` around lines 81 - 82, The current
string-replacement in ArtworkService (line creating highRes from artworkUrl) is
fragile; modify the logic in the method that builds highRes (referencing
artworkUrl and highRes variables in ArtworkService) to use a regex that matches
common Apple artwork size tokens (e.g., \d+x\d+), or at minimum check whether
replacing "100x100" actually changed the URL and, if not, emit a warning/error
log (via your service logger) so we can detect when the expected pattern no
longer applies; update the code to fall back to the original URL only after
logging the mismatch.

29-36: In-memory cache grows without bound.

The cache dictionary is never evicted. For a desktop music app this is unlikely to be a problem in practice, but long-running sessions with diverse libraries could accumulate thousands of entries. Consider adding a cap (e.g., NSCache or an LRU eviction policy) if this becomes a concern.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Services/ArtworkService.swift` around lines 29 - 36, The
in-memory cache (private var cache) grows without bound; replace it with a
capacity-capped cache (e.g., use NSCache<String, NSString> or implement a simple
LRU ring buffer) and preserve thread-safety via the existing cacheQueue: change
the storage from Dictionary to an NSCache or an LRU class, set a sensible
maximum (e.g., maxEntries or totalCostLimit), and update all get/set/remove
usages in ArtworkService to call into the new cache API under cacheQueue.async/
sync as appropriate so eviction happens automatically when the cap is reached.
src/wolfwave/Services/Twitch/Commands/BotCommandDispatcher.swift (1)

119-141: Hardcoded trigger strings duplicate knowledge from the command definitions.

The switch on trigger strings ("!song", "!currentsong", etc.) must be kept in sync with SongCommand.triggers and LastSongCommand.triggers. If a trigger is added or renamed in either command, this map silently falls through to default.

Consider deriving the mapping from the command instances rather than duplicating the trigger literals.

♻️ Example approach
private func cooldownValues(for trigger: String, command: BotCommand) -> (TimeInterval, TimeInterval) {
    let defaults = Foundation.UserDefaults.standard

    if command is SongCommand {
        let globalCD = defaults.object(forKey: AppConstants.UserDefaults.songCommandGlobalCooldown) as? TimeInterval
            ?? command.globalCooldown
        let userCD = defaults.object(forKey: AppConstants.UserDefaults.songCommandUserCooldown) as? TimeInterval
            ?? command.userCooldown
        return (globalCD, userCD)
    } else if command is LastSongCommand {
        let globalCD = defaults.object(forKey: AppConstants.UserDefaults.lastSongCommandGlobalCooldown) as? TimeInterval
            ?? command.globalCooldown
        let userCD = defaults.object(forKey: AppConstants.UserDefaults.lastSongCommandUserCooldown) as? TimeInterval
            ?? command.userCooldown
        return (globalCD, userCD)
    }

    return (command.globalCooldown, command.userCooldown)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Services/Twitch/Commands/BotCommandDispatcher.swift` around
lines 119 - 141, The switch over hardcoded trigger strings in
cooldownValues(for:command:) duplicates trigger definitions and can drift out of
sync; instead detect the command type (e.g., SongCommand vs LastSongCommand)
from the provided command instance and use that to select the appropriate
UserDefaults keys (AppConstants.UserDefaults.songCommandGlobalCooldown /
songCommandUserCooldown or
AppConstants.UserDefaults.lastSongCommandGlobalCooldown /
lastSongCommandUserCooldown), falling back to command.globalCooldown and
command.userCooldown for other command types; update cooldownValues to check "if
command is SongCommand" and "else if command is LastSongCommand" and read
defaults accordingly so trigger literals are not duplicated.
src/wolfwave/Views/WebSocket/WebSocketSettingsView.swift (3)

153-167: Prefer Task.sleep over DispatchQueue.main.asyncAfter for transient UI feedback.

The coding guidelines specify using async/await over DispatchQueue for new async work. The same pattern is repeated for the widget URL copy button (lines 200–205).

♻️ Proposed fix
                    Button {
                        copyToClipboard(connectionURL)
                        copiedConnectionURL = true
-                       DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
-                           copiedConnectionURL = false
-                       }
+                       Task {
+                           try? await Task.sleep(for: .seconds(2))
+                           copiedConnectionURL = false
+                       }
                    } label: {

Apply the same change to the widget URL copy button (lines 200–205).

As per coding guidelines: "Use async/await concurrency instead of DispatchQueue for new async work in Swift"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Views/WebSocket/WebSocketSettingsView.swift` around lines 153 -
167, Replace the DispatchQueue.main.asyncAfter usage with async/await Task.sleep
to handle the transient UI reset: inside the Button action that calls
copyToClipboard(connectionURL) and sets copiedConnectionURL = true, start a Task
and await Task.sleep(for: .seconds(2)) (or Task.sleep(nanoseconds:
2_000_000_000)) before setting copiedConnectionURL = false; do the same change
for the analogous widget URL copy button (the other Button that sets
copiedWidgetURL), ensuring both use Task { await Task.sleep(...) ; copied... =
false } instead of DispatchQueue.main.asyncAfter.

434-438: refreshServerState() directly accesses AppDelegate.shared singleton.

This couples the view tightly to the app delegate. The MVVM guideline for views under Views/ recommends using a ViewModel as an intermediary. For the "Chill" review mode this isn't blocking, but worth noting for future refactoring — it also makes this view untestable in isolation.

Based on learnings: "Use MVVM pattern for views with ViewModels as ObservableObject with Published properties in SwiftUI"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Views/WebSocket/WebSocketSettingsView.swift` around lines 434 -
438, refreshServerState() currently reads AppDelegate.shared directly, coupling
the view to the app delegate; move this logic into a ViewModel (e.g.,
WebSocketSettingsViewModel : ObservableObject) that exposes `@Published`
properties serverState and clientCount and a refreshServerState() method that
reads websocketServer?.state and connectionCount (or accepts an injectable
dependency instead of directly referencing AppDelegate.shared). Change
WebSocketSettingsView to own an `@StateObject` (or accept an `@ObservedObject`) of
WebSocketSettingsViewModel and read serverState and clientCount from the
ViewModel instead of AppDelegate.shared.websocketServer; update calls that
invoked the view's refreshServerState() to call the ViewModel's method.

43-49: Widget URL computation is duplicated with OnboardingOBSWidgetStepView.

The same #if DEBUG / #else URL logic appears identically in OnboardingOBSWidgetStepView.swift (lines 20–28). If the production URL or query parameter format changes, both must be updated in lockstep.

Consider extracting this into a static method on AppConstants.Widget or a shared helper.

♻️ Example: centralize in AppConstants
// In AppConstants.Widget
static func widgetURL(port: Int) -> String {
    `#if` DEBUG
    "http://localhost:3000/widget/?port=\(port)"
    `#else`
    "https://mrdemonwolf.github.io/wolfwave/widget/?port=\(port)"
    `#endif`
}

Then in both views:

private var widgetURL: String {
    AppConstants.Widget.widgetURL(port: storedPort)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Views/WebSocket/WebSocketSettingsView.swift` around lines 43 -
49, Duplicate conditional widget URL logic in WebSocketSettingsView.private var
widgetURL and OnboardingOBSWidgetStepView must be centralized; add a static
helper like AppConstants.Widget.widgetURL(port:) that contains the `#if` DEBUG /
`#else` URL selection using the passed port, then replace both private var
widgetURL computed properties to return AppConstants.Widget.widgetURL(port:
storedPort). Ensure the helper uses the same query parameter format and update
both views to call it so future URL changes are made in one place.
src/wolfwave/Views/Twitch/TwitchViewModel.swift (1)

692-717: testConnection() looks correct; minor: MainActor.run is redundant here.

Since TwitchViewModel is @MainActor and Task { } inherits the actor context, the await MainActor.run { } on line 711 is unnecessary — the closure is already on @MainActor. Not harmful, just slightly verbose.

♻️ Simplify by removing redundant MainActor.run
         Task {
             let isValid = await service.validateToken(token)
-            await MainActor.run {
-                self.statusMessage = isValid
-                    ? "✅ Token is valid — scopes OK"
-                    : "❌ Token is invalid or expired"
-            }
+            self.statusMessage = isValid
+                ? "✅ Token is valid — scopes OK"
+                : "❌ Token is invalid or expired"
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Views/Twitch/TwitchViewModel.swift` around lines 692 - 717, The
MainActor.run call inside testConnection is redundant because TwitchViewModel is
`@MainActor` and Task inherits that actor context; remove the await MainActor.run
{ ... } wrapper and directly assign self.statusMessage within the Task after
await service.validateToken(token) (update the closure in testConnection to set
statusMessage based on isValid without calling MainActor.run).
src/wolfwave/Services/WebSocket/WebSocketServerService.swift (2)

323-324: Hardcoded version "1.0.0" in the welcome message.

Consider reading this from Bundle.main to keep it in sync with the app version automatically.

♻️ Use bundle version
 private func sendWelcome(to connection: NWConnection) {
-    sendJSON(["type": "welcome", "server": "WolfWave", "version": "1.0.0"], to: connection)
+    let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
+    sendJSON(["type": "welcome", "server": "WolfWave", "version": version], to: connection)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Services/WebSocket/WebSocketServerService.swift` around lines
323 - 324, The welcome payload in sendWelcome currently hardcodes "1.0.0";
update sendWelcome to read the app version from Bundle.main (e.g.,
Bundle.main.infoDictionary?["CFBundleShortVersionString"] or CFBundleVersion as
fallback) and pass that value into sendJSON instead of the literal; ensure you
handle the optional safely (provide a sensible default like "0.0.0" if missing)
and keep the call site (sendWelcome(to:) and the sendJSON invocation) unchanged
otherwise.

132-146: Duplicate widget config construction in broadcastWidgetConfig() and sendWidgetConfig(to:).

Both methods build the same dictionary from UserDefaults. Extract a shared helper to keep them in sync.

♻️ Extract a shared config builder
+    private func buildWidgetConfigMessage() -> [String: Any] {
+        let defaults = UserDefaults.standard
+        return [
+            "type": "widget_config",
+            "data": [
+                "theme": defaults.string(forKey: AppConstants.UserDefaults.widgetTheme) ?? "Default",
+                "layout": defaults.string(forKey: AppConstants.UserDefaults.widgetLayout) ?? "Horizontal",
+                "textColor": defaults.string(forKey: AppConstants.UserDefaults.widgetTextColor) ?? "#FFFFFF",
+                "backgroundColor": defaults.string(forKey: AppConstants.UserDefaults.widgetBackgroundColor) ?? "#1A1A2E",
+                "fontFamily": defaults.string(forKey: AppConstants.UserDefaults.widgetFontFamily) ?? "System",
+            ],
+        ]
+    }
+
     func broadcastWidgetConfig() {
-        let defaults = UserDefaults.standard
-        let config: [String: Any] = [...]
-        broadcastJSON(config)
+        broadcastJSON(buildWidgetConfigMessage())
     }
 
     private func sendWidgetConfig(to connection: NWConnection) {
-        let defaults = UserDefaults.standard
-        let config: [String: Any] = [...]
-        sendJSON(config, to: connection)
+        sendJSON(buildWidgetConfigMessage(), to: connection)
     }

Also applies to: 328-341

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Services/WebSocket/WebSocketServerService.swift` around lines
132 - 146, Both broadcastWidgetConfig() and sendWidgetConfig(to:) build the same
widget config from UserDefaults; extract a single helper (e.g.,
buildWidgetConfig() or widgetConfigDictionary()) that reads UserDefaults keys
(AppConstants.UserDefaults.widgetTheme, widgetLayout, widgetTextColor,
widgetBackgroundColor, widgetFontFamily) and returns the [String: Any] payload
(with "type":"widget_config" and "data": ...); then replace the duplicate
dictionary constructions in broadcastWidgetConfig() and sendWidgetConfig(to:) to
call that helper and pass its result to broadcastJSON/send as before so both
methods remain in sync.
src/wolfwave/Views/Shared/UpdateBannerView.swift (2)

76-110: The listening() modifier pattern is fragile — callers must remember to chain it.

If a consumer writes UpdateBannerView() without .listening(), the banner silently never activates. Consider moving the onAppear/onReceive logic directly into the body (e.g., via a background modifier or .onAppear/.onReceive inside the top-level view builder), or document this requirement prominently.

♻️ Inline the listening logic into the body
     var body: some View {
-        if isUpdateAvailable && !isDismissed {
+        Group {
+            if isUpdateAvailable && !isDismissed {
                 HStack(spacing: 10) {
                     // ... existing content ...
                 }
-        }
-    }
-
-    /// Modifier that wires up update state listening.
-    func listening() -> some View {
-        self
-            .onAppear {
+            }
+        }
+        .onAppear {
                 if let appDelegate = AppDelegate.shared,
                    let info = appDelegate.updateChecker?.latestUpdateInfo,
                    info.isUpdateAvailable
                 {
                     latestVersion = info.latestVersion
                     releaseURL = info.releaseURL
                     isUpdateAvailable = true
                 }
-            }
-            .onReceive(
+        }
+        .onReceive(
                 NotificationCenter.default.publisher(
                     for: NSNotification.Name(AppConstants.Notifications.updateStateChanged)
                 )
-            ) { notification in
+        ) { notification in
                 // ... existing handling ...
-            }
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Views/Shared/UpdateBannerView.swift` around lines 76 - 110, The
listening() View modifier is fragile because callers can omit it; move its
onAppear/onReceive logic into UpdateBannerView's body so the update-checking
runs whenever the view is used (no extra chaining required). Specifically,
remove or stop relying on the listening() function and place the same onAppear
block and NotificationCenter publisher handling directly in the top-level view
builder of UpdateBannerView (updating latestVersion, releaseURL,
isUpdateAvailable and isDismissed the same way). Keep the same guard/unwrapping
logic and fallback URL behavior for releaseURL, and ensure you still read
AppDelegate.shared.updateChecker?.latestUpdateInfo on appear and update the
state properties accordingly.

16-111: Missing #Preview block.

As per coding guidelines, SwiftUI views should include a #Preview block.

Add preview
`#Preview` {
    UpdateBannerView()
        .listening()
        .padding()
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Views/Shared/UpdateBannerView.swift` around lines 16 - 111, Add
a SwiftUI preview for UpdateBannerView by appending a `#Preview` block that
instantiates UpdateBannerView() with the listening() modifier (and .padding() if
desired) so the view and its update-state wiring can be inspected in Xcode;
place the block after the UpdateBannerView declaration (referencing
UpdateBannerView and its listening() method) to satisfy the guideline requiring
a preview.
src/wolfwave/Views/Discord/DiscordSettingsView.swift (1)

111-116: Fragile color logic relying on emoji content in the message string.

testResultMessage.contains("✅") couples the UI color to the exact emoji used in the message. Consider a dedicated boolean or enum to track success/failure state instead.

♻️ Example: use a separate state for the result
+    `@State` private var testSucceeded: Bool?
     // ...
     if !testResultMessage.isEmpty {
         Text(testResultMessage)
             .font(.system(size: 12))
-            .foregroundStyle(testResultMessage.contains("✅") ? .green : .red)
+            .foregroundStyle(testSucceeded == true ? .green : .red)
             .transition(.opacity)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Views/Discord/DiscordSettingsView.swift` around lines 111 - 116,
The view currently decides color by checking testResultMessage.contains("✅"),
which is fragile; introduce a dedicated result state (e.g., a Bool like
testSucceeded or an enum like TestResult { case success, failure, none }) in the
DiscordSettingsView and set that state where the testResultMessage is produced,
then change the Text modifier to use that state (e.g.,
.foregroundStyle(testSucceeded ? .green : .red) or switch on TestResult) so the
UI color is driven by the explicit result state rather than emoji content;
update any code paths that set testResultMessage to also set the new state and
remove the emoji-based color check.
docs/app/widget/themes.ts (2)

35-69: fontMap and googleFonts can drift out of sync.

The Google Font names are duplicated between fontMap (lines 42–53) and googleFonts (lines 57–68). Adding a font to one but not the other would cause either a broken CSS reference or a missing preload.

♻️ Derive googleFonts from fontMap to keep them in sync
-const googleFonts = new Set([
-  "Montserrat",
-  "Roboto",
-  "Open Sans",
-  "Lato",
-  "Poppins",
-  "Fira Code",
-  "JetBrains Mono",
-  "Oswald",
-  "Bebas Neue",
-  "Raleway",
-  "Press Start 2P",
-  "Permanent Marker",
-]);
+const builtInFonts = new Set(["System", "Monospaced", "Rounded", "Serif"]);
+const googleFonts = new Set(
+  Object.keys(fontMap).filter((f) => !builtInFonts.has(f))
+);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/app/widget/themes.ts` around lines 35 - 69, fontMap and googleFonts are
duplicated and can drift; replace the hardcoded googleFonts list by deriving it
from fontMap so they stay in sync: keep fontMap as the single source of truth
and construct googleFonts (e.g., new Set(Object.keys(fontMap).filter(name =>
!["System","Monospaced","Rounded","Serif"].includes(name)))) so built-in names
(System, Monospaced, Rounded, Serif) are excluded and all other keys in fontMap
are treated as Google fonts.

245-254: Default-theme color override sets primary, secondary, and progress fill to the same value.

When textColor !== "#FFFFFF", lines 247–249 assign the same color to textPrimary, textSecondary, and progressFillBg. This eliminates contrast between primary and secondary text, which may hurt readability. Consider applying an opacity modifier for textSecondary (e.g., ${config.textColor}CC) to preserve hierarchy.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/app/widget/themes.ts` around lines 245 - 254, In the Default-theme
branch, instead of assigning config.textColor verbatim to textPrimary,
textSecondary, and progressFillBg, keep resolved.textPrimary = config.textColor
but set resolved.textSecondary to a semi-transparent version (e.g., append an
8-bit alpha like "CC" to the hex or convert to rgba at ~80% opacity) to preserve
visual hierarchy, and leave resolved.progressFillBg as the primary color (or a
slightly dimmed variant if needed); update the assignment logic that touches
config.textColor, resolved.textPrimary, resolved.textSecondary, and
resolved.progressFillBg to produce the semi-transparent secondary color reliably
(use an existing color helper or add a small utility to convert hex to hex+alpha
or rgba).
src/wolfwave/Core/AppConstants.swift (1)

208-208: Unnecessary nonisolated qualifier on static constant.

nonisolated is superfluous on a static let in a non-actor enum. No other constant in this file uses it. This likely came from silencing a compiler warning in a different context.

Suggested fix
-        nonisolated static let networkReconnectCooldown: TimeInterval = 60.0
+        static let networkReconnectCooldown: TimeInterval = 60.0
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Core/AppConstants.swift` at line 208, Remove the unnecessary
nonisolated qualifier from the static constant declaration for
networkReconnectCooldown: locate the declaration of nonisolated static let
networkReconnectCooldown: TimeInterval = 60.0 (in AppConstants) and change it to
a plain static let networkReconnectCooldown: TimeInterval = 60.0 so the
qualifier is not used on a static constant in a non-actor enum; no other logic
changes required.
src/wolfwave/Services/Twitch/TwitchChatService.swift (1)

1178-1191: Minor race window between cancelSessionWelcomeTimeout and async timer creation.

cancelSessionWelcomeTimeout() runs synchronously on the calling thread (line 1179), but the new timer is created inside DispatchQueue.main.async (line 1180). If startSessionWelcomeTimeout is called twice in quick succession, the second cancel could run before the first async block creates its timer, leaving the first timer uncancelled. Low likelihood in practice since connectToEventSub is the only caller, but wrapping the cancel inside the same main-queue dispatch would eliminate the window.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Services/Twitch/TwitchChatService.swift` around lines 1178 -
1191, Move the synchronous cancel call onto the main queue to eliminate the
race: instead of calling cancelSessionWelcomeTimeout() before
DispatchQueue.main.async, perform the cancel inside the same
DispatchQueue.main.async block (before scheduling the new Timer) so both
cancellation and creation happen on the main thread; update
startSessionWelcomeTimeout to acquire sessionTimerLock and cancel
sessionWelcomeTimer inside that async block prior to calling
Timer.scheduledTimer to ensure no uncancelled timer remains.
src/wolfwave/Views/SettingsView.swift (1)

378-418: Cooldown sliders lack accessibilityIdentifier / accessibilityLabel.

The command toggle rows have proper accessibility identifiers and labels, but the cooldown sliders (lines 396 and 404) don't. For testing and accessibility compliance, consider adding identifiers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Views/SettingsView.swift` around lines 378 - 418, The
cooldownRow view is missing accessibility identifiers/labels for its two Slider
controls (the ones bound to globalCooldown and userCooldown); update the Slider
for globalCooldown and the Slider for userCooldown inside cooldownRow to include
descriptive accessibilityIdentifier and accessibilityLabel (e.g.,
"globalCooldownSlider" / "Global cooldown slider" and "userCooldownSlider" /
"Per-user cooldown slider") so tests and assistive technologies can target them;
ensure you attach these via the Slider modifiers (accessibilityIdentifier and
accessibilityLabel) so they reference the correct Binding variables
(globalCooldown and userCooldown) and remain unique.
src/wolfwave/WolfWaveApp.swift (3)

440-457: Normal music check interval is a magic number.

The reduced-mode interval uses AppConstants.PowerManagement.reducedMusicCheckInterval but the normal-mode fallback is hardcoded as 5.0 (line 445). Consider adding a constant like AppConstants.Music.defaultCheckInterval for consistency and to avoid drift.

Suggested change

In AppConstants.swift, add:

enum Music {
    // ... existing ...
    /// Default polling interval for music playback checks (seconds)
    static let defaultCheckInterval: TimeInterval = 5.0
}

Then in WolfWaveApp.swift:

 musicMonitor?.updateCheckInterval(
-    reduced ? AppConstants.PowerManagement.reducedMusicCheckInterval : 5.0
+    reduced ? AppConstants.PowerManagement.reducedMusicCheckInterval : AppConstants.Music.defaultCheckInterval
 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/WolfWaveApp.swift` around lines 440 - 457, The
powerStateChanged(_:) method in WolfWaveApp.swift uses a hardcoded 5.0 for the
normal music check interval instead of a named constant; add a new constant
(e.g. AppConstants.Music.defaultCheckInterval: TimeInterval = 5.0) in
AppConstants.swift and replace the literal 5.0 in powerStateChanged(_:) with
AppConstants.Music.defaultCheckInterval so the musicMonitor?.updateCheckInterval
call uses the new constant (refer to PowerStateMonitor.shared.isReducedMode and
AppConstants.PowerManagement.reducedMusicCheckInterval when applying the
change).

429-431: Side-effect-only initialization could be more explicit.

_ = PowerStateMonitor.shared relies on the singleton's init to register system observers. A brief comment or a dedicated method like PowerStateMonitor.shared.activate() would make the intent clearer to future readers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/WolfWaveApp.swift` around lines 429 - 431,
setupPowerStateMonitor currently forces the singleton init via `_ =
PowerStateMonitor.shared`, which is a side-effect-only pattern; replace this
with an explicit activation to make intent clear: add a public method on the
PowerStateMonitor type such as `activate()` (or `startMonitoring()`), have its
initializer avoid side effects, and call `PowerStateMonitor.shared.activate()`
from `setupPowerStateMonitor()` (or alternatively add a short clarifying comment
if you prefer not to change API). Ensure the symbol `PowerStateMonitor.shared`
remains the singleton accessor and update `setupPowerStateMonitor()` to call the
new explicit activation method.

510-512: Artwork URL updated from two paths — potential redundant broadcast.

discordService?.onArtworkResolved (line 511) and fetchArtworkForWidget (line 906) both call websocketServer?.updateArtworkURL. If Discord resolves artwork first, the widget fetch may overwrite it redundantly (or vice versa). Not a bug, but worth noting — if the WebSocket server broadcasts on every updateArtworkURL call, clients may receive duplicate artwork updates for the same track.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/WolfWaveApp.swift` around lines 510 - 512,
discordService?.onArtworkResolved and fetchArtworkForWidget both call
websocketServer?.updateArtworkURL leading to duplicate broadcasts; update one or
both call sites to guard against redundant updates by comparing the new artwork
URL to the currently known URL before invoking websocketServer?.updateArtworkURL
(or delegate deduplication into websocketServer by adding an idempotent check
there). Specifically, in the discordService?.onArtworkResolved closure and/or in
fetchArtworkForWidget, check the existing artwork URL/state and only call
updateArtworkURL when the URL is non-nil and different from the last-sent URL,
or implement the same check inside websocketServer.updateArtworkURL to ignore
identical successive URLs.


Last audited: 2026-02-14
Bundle ID: `com.mrdemonwolf.wolfwave`
Deployment target: macOS 15.6
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Deployment target macOS 15.6 is inconsistent with the installation docs.

docs/content/docs/installation.mdx states "macOS 15.0 or later," but COMPLIANCE.md lists 15.6. macOS 15.6 has not shipped; this is likely a typo. If the true minimum is 15.0, update this line to match.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@COMPLIANCE.md` at line 5, Update the deployment target line in COMPLIANCE.md
that currently reads "Deployment target: macOS 15.6" to match the installation
docs; change it to "Deployment target: macOS 15.0" (or "macOS 15.0 or later" if
you prefer consistency with docs/content/docs/installation.mdx) so both sources
specify the same minimum macOS requirement.

Comment on lines 99 to +104
A guided 3-step onboarding wizard walks new users through initial configuration on first launch:

1. **Welcome** — Feature overview highlighting Music Monitoring, Twitch Chat Bot, Discord Rich Presence, Stream Overlays, and Menu Bar integration
2. **Twitch Connection** — Optional OAuth Device Code flow to connect your Twitch account (skippable)
3. **Discord Rich Presence** — Visual preview of what your Discord profile will look like, with a toggle to enable (skippable)
1. **Welcome** — App overview with feature highlights
2. **Twitch Connection** — Optional OAuth Device Code flow to link your Twitch account
3. **Discord Rich Presence** — Optional toggle to enable listening status on Discord
4. **OBS Stream Widget** — Optional toggle to enable the WebSocket server with widget URL
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Step count mismatch: "3-step" but 4 steps listed.

Line 99 says "A guided 3-step onboarding wizard" but four steps are listed immediately below (Welcome, Twitch, Discord, OBS Widget). Update to "4-step".

Fix
-A guided 3-step onboarding wizard walks new users through initial configuration on first launch:
+A guided 4-step onboarding wizard walks new users through initial configuration on first launch:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
A guided 3-step onboarding wizard walks new users through initial configuration on first launch:
1. **Welcome** — Feature overview highlighting Music Monitoring, Twitch Chat Bot, Discord Rich Presence, Stream Overlays, and Menu Bar integration
2. **Twitch Connection** — Optional OAuth Device Code flow to connect your Twitch account (skippable)
3. **Discord Rich Presence** — Visual preview of what your Discord profile will look like, with a toggle to enable (skippable)
1. **Welcome** — App overview with feature highlights
2. **Twitch Connection** — Optional OAuth Device Code flow to link your Twitch account
3. **Discord Rich Presence** — Optional toggle to enable listening status on Discord
4. **OBS Stream Widget** — Optional toggle to enable the WebSocket server with widget URL
A guided 4-step onboarding wizard walks new users through initial configuration on first launch:
1. **Welcome** — App overview with feature highlights
2. **Twitch Connection** — Optional OAuth Device Code flow to link your Twitch account
3. **Discord Rich Presence** — Optional toggle to enable listening status on Discord
4. **OBS Stream Widget** — Optional toggle to enable the WebSocket server with widget URL
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/content/docs/features.mdx` around lines 99 - 104, Update the onboarding
description header so the step count matches the list: change the phrase "A
guided 3-step onboarding wizard" to "A guided 4-step onboarding wizard" in the
content that introduces the steps (the sentence starting with "A guided 3-step
onboarding wizard walks new users...") so the header aligns with the four listed
steps (Welcome, Twitch Connection, Discord Rich Presence, OBS Stream Widget).

Comment on lines +159 to +166
nonisolated static func exportLogFile() -> URL? {
let url = logFileURL
// Flush any pending writes
fileLock.lock()
fileHandle?.synchronizeFile()
fileLock.unlock()
return FileManager.default.fileExists(atPath: url.path) ? url : nil
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Data race: _logFileURL is read outside fileLock in exportLogFile(), and the lock pattern is inconsistent with writeToFile.

logFileURL (line 160) reads and potentially writes the nonisolated(unsafe) stored property _logFileURL before fileLock is acquired (line 162). writeToFile always accesses _logFileURL (via logFileURL) while holding fileLock. If exportLogFile() and the first-ever log() call execute concurrently, there is an unguarded concurrent read-write on _logFileURL.

Additionally, writeToFile uses defer { fileLock.unlock() } for safe release, while exportLogFile() does a manual lock()/unlock() pair — an early return or future change between the two calls risks a deadlock.

🔒 Proposed fix
 nonisolated static func exportLogFile() -> URL? {
-    let url = logFileURL
-    // Flush any pending writes
     fileLock.lock()
+    defer { fileLock.unlock() }
+    let url = logFileURL
+    // Flush any pending writes
     fileHandle?.synchronizeFile()
-    fileLock.unlock()
     return FileManager.default.fileExists(atPath: url.path) ? url : nil
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
nonisolated static func exportLogFile() -> URL? {
let url = logFileURL
// Flush any pending writes
fileLock.lock()
fileHandle?.synchronizeFile()
fileLock.unlock()
return FileManager.default.fileExists(atPath: url.path) ? url : nil
}
nonisolated static func exportLogFile() -> URL? {
fileLock.lock()
defer { fileLock.unlock() }
let url = logFileURL
// Flush any pending writes
fileHandle?.synchronizeFile()
return FileManager.default.fileExists(atPath: url.path) ? url : nil
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Core/Logger.swift` around lines 159 - 166, exportLogFile()
currently reads logFileURL (which accesses the nonisolated(unsafe) stored
property _logFileURL) before acquiring fileLock and uses manual lock/unlock,
risking a race with writeToFile and inconsistent unlock behavior; fix by
acquiring fileLock at the start of nonisolated static func exportLogFile(), then
read logFileURL while the lock is held, call fileHandle?.synchronizeFile() and
determine existence, and always release the lock with defer { fileLock.unlock()
} (matching writeToFile's pattern) so reads/writes of _logFileURL are serialized
and unlock is guaranteed.

Comment on lines +26 to +67
final class PowerStateMonitor {
static let shared = PowerStateMonitor()

/// Whether the system is in a reduced-power state (Low Power Mode or serious/critical thermal pressure).
private(set) var isReducedMode: Bool = false

private init() {
updateState()

NotificationCenter.default.addObserver(
self,
selector: #selector(handleChange),
name: .NSProcessInfoPowerStateDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleChange),
name: ProcessInfo.thermalStateDidChangeNotification,
object: nil
)
}

@objc private func handleChange() {
updateState()
}

private func updateState() {
let info = ProcessInfo.processInfo
let newValue = info.isLowPowerModeEnabled
|| info.thermalState == .serious
|| info.thermalState == .critical

guard newValue != isReducedMode else { return }
isReducedMode = newValue

NotificationCenter.default.post(
name: NSNotification.Name(AppConstants.Notifications.powerStateChanged),
object: nil,
userInfo: ["isReducedMode": newValue]
)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

isReducedMode can be read/written from multiple threads without synchronization.

System power notifications can fire on arbitrary threads, and consumers (e.g., UpdateCheckerService) may read isReducedMode from yet another thread. Without a lock, concurrent reads and writes to isReducedMode constitute a data race.

🔒 Proposed fix: add NSLock
 final class PowerStateMonitor {
     static let shared = PowerStateMonitor()

-    /// Whether the system is in a reduced-power state (Low Power Mode or serious/critical thermal pressure).
-    private(set) var isReducedMode: Bool = false
+    private var _isReducedMode: Bool = false
+    private let lock = NSLock()
+
+    /// Whether the system is in a reduced-power state (Low Power Mode or serious/critical thermal pressure).
+    var isReducedMode: Bool {
+        lock.withLock { _isReducedMode }
+    }

     // ...

     private func updateState() {
         let info = ProcessInfo.processInfo
         let newValue = info.isLowPowerModeEnabled
             || info.thermalState == .serious
             || info.thermalState == .critical

-        guard newValue != isReducedMode else { return }
-        isReducedMode = newValue
+        let changed: Bool = lock.withLock {
+            guard newValue != _isReducedMode else { return false }
+            _isReducedMode = newValue
+            return true
+        }
+        guard changed else { return }

         NotificationCenter.default.post(
             name: NSNotification.Name(AppConstants.Notifications.powerStateChanged),
             object: nil,
             userInfo: ["isReducedMode": newValue]
         )
     }
 }

As per coding guidelines: "Use NSLock for thread safety when mutating shared state in services"

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
final class PowerStateMonitor {
static let shared = PowerStateMonitor()
/// Whether the system is in a reduced-power state (Low Power Mode or serious/critical thermal pressure).
private(set) var isReducedMode: Bool = false
private init() {
updateState()
NotificationCenter.default.addObserver(
self,
selector: #selector(handleChange),
name: .NSProcessInfoPowerStateDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleChange),
name: ProcessInfo.thermalStateDidChangeNotification,
object: nil
)
}
@objc private func handleChange() {
updateState()
}
private func updateState() {
let info = ProcessInfo.processInfo
let newValue = info.isLowPowerModeEnabled
|| info.thermalState == .serious
|| info.thermalState == .critical
guard newValue != isReducedMode else { return }
isReducedMode = newValue
NotificationCenter.default.post(
name: NSNotification.Name(AppConstants.Notifications.powerStateChanged),
object: nil,
userInfo: ["isReducedMode": newValue]
)
}
final class PowerStateMonitor {
static let shared = PowerStateMonitor()
private var _isReducedMode: Bool = false
private let lock = NSLock()
/// Whether the system is in a reduced-power state (Low Power Mode or serious/critical thermal pressure).
var isReducedMode: Bool {
lock.withLock { _isReducedMode }
}
private init() {
updateState()
NotificationCenter.default.addObserver(
self,
selector: `#selector`(handleChange),
name: .NSProcessInfoPowerStateDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: `#selector`(handleChange),
name: ProcessInfo.thermalStateDidChangeNotification,
object: nil
)
}
`@objc` private func handleChange() {
updateState()
}
private func updateState() {
let info = ProcessInfo.processInfo
let newValue = info.isLowPowerModeEnabled
|| info.thermalState == .serious
|| info.thermalState == .critical
let changed: Bool = lock.withLock {
guard newValue != _isReducedMode else { return false }
_isReducedMode = newValue
return true
}
guard changed else { return }
NotificationCenter.default.post(
name: NSNotification.Name(AppConstants.Notifications.powerStateChanged),
object: nil,
userInfo: ["isReducedMode": newValue]
)
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/Core/PowerStateMonitor.swift` around lines 26 - 67,
isReducedMode is accessed from multiple threads without synchronization; add a
private NSLock (e.g., private let lock = NSLock()) plus a private backing
storage (e.g., private var _isReducedMode: Bool) and make isReducedMode a
computed property that returns _isReducedMode while holding the lock; in
updateState (and handleChange if it touches state) acquire the lock when
comparing newValue to _isReducedMode and when assigning _isReducedMode, then
release before posting the notification. This ensures thread-safe reads/writes
for PowerStateMonitor's isReducedMode while keeping the NotificationCenter logic
in init, updateState, handleChange unchanged.

Comment on lines +9 to +10
<key>com.apple.security.network.server</key>
<true/>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistency: ENABLE_INCOMING_NETWORK_CONNECTIONS = NO in both Debug and Release build configs contradicts this entitlement.

project.pbxproj has ENABLE_INCOMING_NETWORK_CONNECTIONS = NO set for both build configurations (Debug line 354, Release line 413), while both wolfwave.dev.entitlements and wolfwave.entitlements now declare com.apple.security.network.server = true. The entitlements file is authoritative at signing time, so the WebSocket server should work — but the stale build setting is misleading and could confuse future Capabilities UI edits (which sync the setting with the entitlement).

Consider updating the build setting to ENABLE_INCOMING_NETWORK_CONNECTIONS = YES to keep it consistent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/wolfwave.dev.entitlements` around lines 9 - 10, The build
setting ENABLE_INCOMING_NETWORK_CONNECTIONS is set to NO while your entitlements
(com.apple.security.network.server = true in wolfwave.dev.entitlements and
wolfwave.entitlements) declare incoming network permissions; update the Xcode
project build setting named ENABLE_INCOMING_NETWORK_CONNECTIONS to YES for both
Debug and Release configurations (the entries currently in project.pbxproj) so
the project setting matches the entitlements and won’t confuse the Capabilities
UI or future edits.

Comment on lines +395 to +415
private func addCopyWidgetURLItem(to menu: NSMenu) {
let item = NSMenuItem(
title: "Copy Widget URL",
action: #selector(copyWidgetURL),
keyEquivalent: ""
)
item.image = NSImage(
systemSymbolName: "link",
accessibilityDescription: "Copy Widget URL"
)
menu.addItem(item)
}

@objc private func copyWidgetURL() {
let port = UserDefaults.standard.object(forKey: AppConstants.UserDefaults.websocketServerPort) as? UInt16
?? AppConstants.WebSocketServer.defaultPort
let url = "http://localhost:\(port)"
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url, forType: .string)
Log.info("Widget URL copied to clipboard: \(url)", category: "App")
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check how WebSocketServerService serves the widget / what URL the widget expects
rg -n "localhost" --type swift -C 3 -g '!WolfWaveApp.swift'
echo "---"
# Check widget/overlay source for its expected URL
fd -e tsx -e ts -e html --exec grep -l "localhost" {} \;

Repository: MrDemonWolf/wolfwave

Length of output: 1973


🏁 Script executed:

# Check the widget page source code
cat -n ./docs/app/widget/page.tsx | head -50

Repository: MrDemonWolf/wolfwave

Length of output: 1797


🏁 Script executed:

# Check how the widget connects with the port parameter
rg -n "port\|websocket\|ws://" ./docs/app/widget/page.tsx -C 2

Repository: MrDemonWolf/wolfwave

Length of output: 46


🏁 Script executed:

# Check more of the widget page to see WebSocket connection
cat -n ./docs/app/widget/page.tsx | head -100

Repository: MrDemonWolf/wolfwave

Length of output: 3757


🏁 Script executed:

# Search for the WebSocket connection initialization
rg -n "new WebSocket\|ws://\|wss://" ./docs/app/widget/page.tsx -A 5

Repository: MrDemonWolf/wolfwave

Length of output: 46


🏁 Script executed:

# Search for WebSocket initialization more broadly
grep -n "WebSocket\|ws" ./docs/app/widget/page.tsx

Repository: MrDemonWolf/wolfwave

Length of output: 488


🏁 Script executed:

# Get line numbers for WebSocketSettingsView to reference in the rewrite
rg -n "widgetURL" src/wolfwave/Views/WebSocket/WebSocketSettingsView.swift

Repository: MrDemonWolf/wolfwave

Length of output: 271


Update copyWidgetURL() to include widget path and port parameter.

copyWidgetURL() returns http://localhost:\(port) but should match the pattern used in WebSocketSettingsView and OnboardingOBSWidgetStepView: http://localhost:3000/widget/?port=\(port) for DEBUG builds. The missing /widget path and ?port= query parameter prevent the widget from loading and connecting properly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wolfwave/WolfWaveApp.swift` around lines 395 - 415, The Copy Widget URL
currently produced by copyWidgetURL() is missing the widget path and port query
parameter; update copyWidgetURL() so in DEBUG builds it constructs the URL as
"http://localhost:3000/widget/?port=\(port)" (or use the resolved port variable
instead of hardcoding 3000 if needed) and writes that to the pasteboard and log;
implement this with a platform conditional (`#if` DEBUG / `#else` / `#endif`) inside
copyWidgetURL() (referencing copyWidgetURL and addCopyWidgetURLItem to locate
the code) so the pasteboard and Log.info contain the new "/widget/?port="
pattern.

Comment on lines +63 to +73
func testServiceInitializesWithDefaultPort() {
let service = WebSocketServerService()
XCTAssertEqual(service.state, .stopped)
XCTAssertEqual(service.connectionCount, 0)
}

func testServiceInitializesWithCustomPort() {
let service = WebSocketServerService(port: 9999)
XCTAssertEqual(service.state, .stopped)
XCTAssertEqual(service.connectionCount, 0)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find WebSocketServerService file and check for port property
fd WebSocketServerService --type f

# Search for the port property definition
rg -n 'var port' --type swift | head -20

# Search for AppConstants WebSocketServer configuration
rg -n 'defaultPort' --type swift | head -20

Repository: MrDemonWolf/wolfwave

Length of output: 2180


🏁 Script executed:

#!/bin/bash
# Get the WebSocketServerService class definition to verify port property
fd -t f 'WebSocketServerService\.swift$' -x cat {} | head -100

Repository: MrDemonWolf/wolfwave

Length of output: 3118


Service initialization tests don't assert on the port value.

testServiceInitializesWithDefaultPort and testServiceInitializesWithCustomPort verify state and connectionCount but never assert that the port is actually set to the expected value (8765 and 9999 respectively). Add port assertions:

Suggested addition
 func testServiceInitializesWithDefaultPort() {
     let service = WebSocketServerService()
+    XCTAssertEqual(service.port, AppConstants.WebSocketServer.defaultPort)
     XCTAssertEqual(service.state, .stopped)
     XCTAssertEqual(service.connectionCount, 0)
 }

 func testServiceInitializesWithCustomPort() {
     let service = WebSocketServerService(port: 9999)
+    XCTAssertEqual(service.port, 9999)
     XCTAssertEqual(service.state, .stopped)
     XCTAssertEqual(service.connectionCount, 0)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func testServiceInitializesWithDefaultPort() {
let service = WebSocketServerService()
XCTAssertEqual(service.state, .stopped)
XCTAssertEqual(service.connectionCount, 0)
}
func testServiceInitializesWithCustomPort() {
let service = WebSocketServerService(port: 9999)
XCTAssertEqual(service.state, .stopped)
XCTAssertEqual(service.connectionCount, 0)
}
func testServiceInitializesWithDefaultPort() {
let service = WebSocketServerService()
XCTAssertEqual(service.port, AppConstants.WebSocketServer.defaultPort)
XCTAssertEqual(service.state, .stopped)
XCTAssertEqual(service.connectionCount, 0)
}
func testServiceInitializesWithCustomPort() {
let service = WebSocketServerService(port: 9999)
XCTAssertEqual(service.port, 9999)
XCTAssertEqual(service.state, .stopped)
XCTAssertEqual(service.connectionCount, 0)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/WolfWaveTests/WebSocketServerServiceTests.swift` around lines 63 - 73,
Add assertions in testServiceInitializesWithDefaultPort and
testServiceInitializesWithCustomPort to verify the WebSocketServerService.port
property is set correctly: in testServiceInitializesWithDefaultPort assert
service.port == 8765, and in testServiceInitializesWithCustomPort assert
service.port == 9999; locate these tests (functions
testServiceInitializesWithDefaultPort and testServiceInitializesWithCustomPort)
and add the port equality assertions alongside the existing state and
connectionCount checks.

@nathanialhenniges nathanialhenniges merged commit 6c03ba3 into main Feb 20, 2026
2 checks passed
@coderabbitai coderabbitai bot mentioned this pull request Mar 19, 2026
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant