Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file.
### Added

- **Custom commands can pick how they answer.** Each custom command now has a "How it's sent" setting: reply to the chatter (the default, and what existing commands keep doing), a plain chat message, or a Twitch announcement. Announcements need the signed-in account to be a mod and a one-time Twitch reconnect to grant the new permission; if Twitch refuses, the command falls back to a reply and the Custom Commands card tells you why.
- **Links that open Settings right where you need them.** WolfWave now answers `wolfwave://settings/<pane>` and `wolfwave://settings/<pane>/<section>` links, so the docs (or your own notes) can open the Twitch pane, scroll to Custom Commands, and flash it so you can't miss it. Works whether Settings is closed or already open on another pane. A link to something that doesn't exist just opens General. The full list of pane and section names is on the [Settings](https://mrdemonwolf.github.io/wolfwave/docs/settings#deep-links) page.
- **Five new Stream Deck keys.** **Announce Song** posts what's playing to your chat, same wording as `!song`. **Reject Request** drops the request that's playing and tells chat it went, so the requester isn't left wondering where their song came from and vanished (Skip just moves on silently). **Block Requester** bars the person who requested the current song, held for a second so you can't do it by accident. **Request Access** cycles who can request (Everyone, Subscribers, VIPs & Subs, Mods) and shows the current setting right on the key. **Now Playing** shows the track over its album art and does nothing when pressed.
- **You can block people from requesting, not just songs.** Settings → Song Requests → Blocklist now takes a Twitch username as well as song titles and artists. A blocked person's requests are turned away before WolfWave even looks the song up.
- **A Sponsor link you can turn off.** WolfWave now has a Sponsor item in the menu bar's Help submenu as well as the Help menu and the About pane. If you'd rather not be asked, turn off "Show sponsor links" in Settings, About. The toggle stays put so you can turn it back on, and WolfWave is free and complete either way.
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ Your music plays. Everything else keeps up.
- **Stream Widgets.** Drop-in browser-source overlay powered by a local WebSocket server with a per-install read-only overlay token, five themes (`Default`, `Dark`, `Light`, `Glass`, `Neon`), and five layouts (`Horizontal`, `Vertical`, `Compact`, `Vinyl`, `Classic`). Two-PC streamers can receive now-playing data from a second machine on the LAN.
- **Queue Ticker Overlay.** Opt-in `?queueTicker=1` panel showing the next 3 song requests, title and requester, so viewers see their spot in line without asking chat.
- **OBS-friendly by design.** Visual progress is batched at 10 Hz, rendering sleeps while hidden or unloaded, and reduced-motion mode removes continuous animation work.
- **Settings deep links.** `wolfwave://settings/twitch/custom-commands` opens Settings on the Twitch pane, scrolls to Custom Commands, and flashes it. Every pane and card has a stable name, listed in the [Settings docs](https://mrdemonwolf.github.io/wolfwave/docs/settings#deep-links).
- **Stream Deck Control.** A separate control token authorizes play/pause, skip, request-queue, announce, block, and overlay-toggle commands (control protocol v3, twelve keys) only from this Mac; the read-only overlay token can never run them. The Elgato plugin lives at `apps/streamdeck/`, and its protocol is documented in [Stream Deck Control API](apps/native/docs/streamdeck-control-api.md).

### History & Stats
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ The next release. These ship on the [Nightly channel](/docs/nightly) off `main`
### Added

- **Custom commands can pick how they answer.** Each custom command now has a "How it's sent" setting: reply to the chatter (the default, and what existing commands keep doing), a plain chat message, or a Twitch announcement. Announcements need the signed-in account to be a mod and a one-time Twitch reconnect to grant the new permission; if Twitch refuses, the command falls back to a reply and the Custom Commands card tells you why.
- **Links that open Settings right where you need them.** WolfWave now answers `wolfwave://settings/<pane>` and `wolfwave://settings/<pane>/<section>` links, so the docs can open the Twitch pane, scroll to Custom Commands, and flash it. Works whether Settings is closed or already open on another pane. A link to something that doesn't exist just opens General. Full list of names under [Deep links](/docs/settings#deep-links).
- **Five new Stream Deck keys.** **Announce Song** posts what's playing to your chat, same wording as `!song`. **Reject Request** drops the request that's playing and tells chat it went, so the requester isn't left wondering (Skip just moves on silently). **Block Requester** bars the person who requested the current song, held for a second so you can't do it by accident. **Request Access** cycles who can request and shows the current setting on the key. **Now Playing** shows the track over its album art. See [Stream Deck](/docs/streamdeck).
- **You can block people from requesting, not just songs.** Settings → Song Requests → Blocklist now takes a Twitch username as well as song titles and artists. A blocked person's requests are turned away before WolfWave even looks the song up.
- **A Sponsor link you can turn off.** WolfWave now has a Sponsor item in the menu bar's Help submenu as well as the Help menu and the About pane. If you'd rather not be asked, turn off "Show sponsor links" in [Settings, About](/docs/settings#about). WolfWave is free and complete either way.
Expand Down
63 changes: 62 additions & 1 deletion apps/docs/content/docs/settings.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,14 @@ keywords:
---

Open Settings from the menu bar wolf icon. A sidebar lists every pane; this page
tells you what lives where, with links to the deep-dives.
tells you what lives where, with links to the deep-dives. Each pane heading
below also carries a `wolfwave://` link that opens WolfWave straight to it
(see [Deep links](#deep-links)).

## General

<small>Open in WolfWave: [`wolfwave://settings/general`](wolfwave://settings/general)</small>

How WolfWave tracks your music and where it shows up. Four sections in one
scrollable pane:

Expand All @@ -44,6 +48,8 @@ Appearance and notification choices are preferences, so they travel with

## Song Requests

<small>Open in WolfWave: [`wolfwave://settings/song-requests`](wolfwave://settings/song-requests)</small>

The guided setup, the request policy preset (Open, Sub Only, Channel Point Only,
Custom), queue rules, approval screening, hold mode, vote-skip, and the live
queue view. The behavior is documented in
Expand All @@ -52,6 +58,8 @@ queue view. The behavior is documented in

## Stream Widgets

<small>Open in WolfWave: [`wolfwave://settings/stream-widgets`](wolfwave://settings/stream-widgets)</small>

The OBS overlay: turn the connection on, drop in the ready-made browser source,
and style it. Cards run in the order you need them, so the raw WebSocket feed
(port, read-only overlay token, and the two addresses) sits last, under **Build
Expand All @@ -60,6 +68,8 @@ and the message contract are in the [widget guide](/docs/widget).

## Stream Deck

<small>Open in WolfWave: [`wolfwave://settings/stream-deck`](wolfwave://settings/stream-deck)</small>

The control side, on its own page. Holds the **Allow Stream Deck commands**
switch, the same-Mac control token, and the setup steps. It is separate from
Stream Widgets on purpose: the overlay token is read-only and reachable across
Expand All @@ -69,19 +79,25 @@ this Mac. Turning commands off leaves your overlay running. Full walkthrough:

## History & Stats

<small>Open in WolfWave: [`wolfwave://settings/history-stats`](wolfwave://settings/history-stats)</small>

Opt-in, on-device listening history with charts and the Monthly Wrap. What
counts as a play and how retention works:
[Listening History](/docs/listening-history).

## Twitch

<small>Open in WolfWave: [`wolfwave://settings/twitch`](wolfwave://settings/twitch)</small>

Connect your Twitch account (device code — no password typed into WolfWave),
manage the chat bot, built-in command toggles and aliases, custom commands (each
with its own delivery mode: reply, plain message, or announcement), and
cooldowns. Command reference: [Bot Commands](/docs/bot-commands).

## Discord

<small>Open in WolfWave: [`wolfwave://settings/discord`](wolfwave://settings/discord)</small>

Rich Presence: show what you're listening to on your Discord profile, with
optional buttons and an idle status when nothing is playing.

Expand All @@ -97,11 +113,15 @@ instead of always guessing that Discord is closed:

## Software Update

<small>Open in WolfWave: [`wolfwave://settings/software-update`](wolfwave://settings/software-update)</small>

Check for updates, switch between the Stable and Nightly channels, and see
what's new. Channel details: [Nightly Builds](/docs/nightly).

## Advanced

<small>Open in WolfWave: [`wolfwave://settings/advanced`](wolfwave://settings/advanced)</small>

Settings [backup and restore](/docs/backup), log export, the bug-report flow,
and opt-in on-device diagnostics. **Export Logs** writes one diagnostics file:
an environment summary (version, macOS, install method), the last crash if
Expand All @@ -111,6 +131,8 @@ recovery callout appears here and says what crashed.

## About

<small>Open in WolfWave: [`wolfwave://settings/about`](wolfwave://settings/about)</small>

Version, credits, license, and links to the docs, community Discord, and
acknowledgements.

Expand All @@ -119,6 +141,45 @@ and you get a Sponsor item here, in the menu bar's Help submenu, and in the
Help menu. Turn it off and all three disappear — the toggle itself stays, so you
can turn it back on. WolfWave is free and fully functional either way.

## Deep links

WolfWave registers the `wolfwave://` URL scheme. A link opens Settings on a
pane, scrolls to a card, and flashes it for a moment. It works whether Settings
is closed or already open somewhere else. Anything WolfWave doesn't recognise
opens General, so a stale link never errors.

```
wolfwave://settings General
wolfwave://settings/<pane> one pane
wolfwave://settings/<pane>/<section> one card inside that pane
```

Names are stable identifiers, not the titles you see on screen, so they survive
copy changes.

| Pane | `<pane>` | `<section>` |
|---|---|---|
| General | `general` | `music`, `visibility`, `appearance`, `notifications` |
| Song Requests | `song-requests` | `vote-skip`, `queue`, `access`, `queue-settings`, `playback`, `commands`, `redemptions`, `blocklist` |
| Stream Widgets | `stream-widgets` | `server`, `browser-source`, `appearance`, `custom-overlay` |
| Stream Deck | `stream-deck` | `control`, `token`, `setup` |
| History & Stats | `history-stats` | `tracking`, `stats-command`, `retention`, `danger-zone` |
| Twitch | `twitch` | `account`, `live-only`, `commands`, `custom-commands` |
| Discord | `discord` | `presence`, `buttons`, `playlist`, `behavior` |
| Software Update | `software-update` | `updates` |
| Advanced | `advanced` | `diagnostics`, `artwork-cache`, `backup`, `danger-zone` |
| About | `about` | |

Some cards only exist once a feature is on (Song Requests cards need setup
finished and the feature enabled; History cards need history on). A link to a
card that isn't showing opens the pane and stops there.

Try one from Terminal:

```bash
open "wolfwave://settings/twitch/custom-commands"
```

## Debug

Only exists in development builds: inspectors, service controls, a live log
Expand Down
3 changes: 0 additions & 3 deletions apps/native/WolfWave/Core/AppConstants+Discord.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,6 @@ import Foundation
extension AppConstants {
/// Discord Rich Presence constants.
nonisolated enum Discord {
/// Settings section identifier for Discord configuration
static let settingsSection = "discordPresence"

/// IPC socket filename prefix (append 0-9 to find active socket)
static let ipcSocketPrefix = "discord-ipc-"

Expand Down
3 changes: 0 additions & 3 deletions apps/native/WolfWave/Core/AppConstants+Twitch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@ extension AppConstants {
/// Base URL for Twitch Helix API endpoints
static let apiBaseURL = "https://api.twitch.tv/helix"

/// Settings section identifier for Twitch configuration
static let settingsSection = "twitchIntegration"

/// Timeout in seconds for receiving the session_welcome WebSocket message
static let sessionWelcomeTimeout: TimeInterval = 10.0

Expand Down
5 changes: 0 additions & 5 deletions apps/native/WolfWave/Core/AppConstants+UserDefaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,6 @@ extension AppConstants {
/// authenticated account. Never used for connection decisions.
static let twitchPendingImportedChannelName = "twitchPendingImportedChannelName"

/// Settings section to open next time (String, "twitchIntegration", etc.)
static let selectedSettingsSection = "selectedSettingsSection"

/// Whether WebSocket integration is enabled (Bool, default: false)
static let websocketEnabled = "websocketEnabled"

Expand Down Expand Up @@ -478,7 +475,6 @@ extension AppConstants {
twitchReauthNeeded,
twitchChannelName,
twitchPendingImportedChannelName,
selectedSettingsSection,
websocketEnabled,
streamDeckControlEnabled,
currentSongCommandEnabled,
Expand Down Expand Up @@ -872,7 +868,6 @@ extension AppConstants {
lastResolvedMusicPermission,
lastLaunchCrashed,
lastCrashSummary,
selectedSettingsSection,
hasCompletedOnboarding,
diagnosticsLaunchCount,
updateSkippedVersion,
Expand Down
4 changes: 4 additions & 0 deletions apps/native/WolfWave/Core/AppConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,10 @@ nonisolated enum AppConstants {
/// Standard card padding
static let cardPadding: CGFloat = DSDimension.Settings.cardPadding

/// Delay between a deep link selecting a pane and scrolling to its
/// section, so the pane has mounted its `DeepLinkAnchor` ids first.
static let deepLinkScrollDelayMs = 120

/// Standard card corner radius (matches macOS 26 Liquid Glass card radius).
static let cardCornerRadius: CGFloat = DSDimension.Settings.cardCornerRadius

Expand Down
3 changes: 1 addition & 2 deletions apps/native/WolfWave/Core/AppDelegate+MenuBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -811,8 +811,7 @@ extension AppDelegate {
}
} else {
// Connecting requires channel + credentials, open Twitch settings
Preferences.setSelectedSettingsSection(AppConstants.Twitch.settingsSection)
openSettings()
navigateSettings(to: SettingsDeepLink(pane: .twitchIntegration))
}
}

Expand Down
3 changes: 1 addition & 2 deletions apps/native/WolfWave/Core/AppDelegate+Services.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1006,8 +1006,7 @@ extension AppDelegate {
}

private func openSettingsToTwitch() {
Preferences.setSelectedSettingsSection(AppConstants.Twitch.settingsSection)
openSettings()
navigateSettings(to: SettingsDeepLink(pane: .twitchIntegration))
}

/// Starts the app-lifetime Twitch validation owner. Kept async so the
Expand Down
19 changes: 19 additions & 0 deletions apps/native/WolfWave/Core/AppDelegate+Windows.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ extension AppDelegate {
}
}

/// Opens Settings at a specific pane (and optional section). Works whether
/// the window is closed or already open on another pane: `SettingsView`
/// observes `SettingsNavigation.shared.pending`.
func navigateSettings(to deepLink: SettingsDeepLink) {
SettingsNavigation.shared.pending = deepLink
openSettings()
}

/// `wolfwave://settings/<pane>[/<section>]` entry point. Registered via
/// `CFBundleURLTypes` in `Info.plist`. Only the first URL is honoured;
/// anything malformed lands on General (see `SettingsDeepLink.parse`).
func application(_ application: NSApplication, open urls: [URL]) {
guard let url = urls.first else { return }
let link = SettingsDeepLink.parse(url)
// Only the resolved slugs are logged; the raw URL is caller-controlled.
Log.info("Settings deep link", fields: ["pane": link.pane.slug, "section": link.section ?? "-"])
navigateSettings(to: link)
}

/// Shows the system standard About panel from the menu bar.
///
/// About lives in two surfaces with intentionally different presentations:
Expand Down
4 changes: 2 additions & 2 deletions apps/native/WolfWave/Core/NotificationPayloads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,8 @@ extension NotificationCenter {

/// Posts `.openSettingsRequested`: a bare signal asking `SettingsSceneBridge`
/// (hosted in the hidden helper window) to invoke the live `openSettings`
/// environment action. Carries no payload; callers persist any requested
/// section through `Preferences.setSelectedSettingsSection` first.
/// environment action. Carries no payload; callers that want a specific
/// pane set `SettingsNavigation.shared.pending` first (`navigateSettings(to:)`).
nonisolated func postOpenSettingsRequested() {
post(name: .openSettingsRequested, object: nil)
}
Expand Down
10 changes: 0 additions & 10 deletions apps/native/WolfWave/Core/Preferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,16 +207,6 @@ nonisolated enum Preferences {

// MARK: - Settings / UI

/// Currently-selected sidebar section in the Settings window. `nil` when
/// the user has not navigated yet.
static var selectedSettingsSection: String? {
defaults.string(forKey: AppConstants.UserDefaults.selectedSettingsSection)
}

static func setSelectedSettingsSection(_ value: String) {
defaults.set(value, forKey: AppConstants.UserDefaults.selectedSettingsSection)
}

/// Dock visibility mode persisted by the App Visibility setting (raw value).
static var dockVisibility: String? {
defaults.string(forKey: AppConstants.UserDefaults.dockVisibility)
Expand Down
71 changes: 71 additions & 0 deletions apps/native/WolfWave/Core/SettingsDeepLink.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//
// SettingsDeepLink.swift
// WolfWave
//
// Created by Nathanial Henniges on 2026-09-01.
// Copyright © 2026 MrDemonWolf, Inc. All rights reserved.
//

import Foundation

/// A parsed `wolfwave://settings/<pane>[/<section>]` URL.
///
/// Parsing never fails: anything that is not a well-formed settings link
/// resolves to the General pane, which is the safe landing spot the ticket
/// asks for. An unknown section on a known pane keeps the pane and drops
/// the section, so a stale docs anchor still opens the right place.
nonisolated struct SettingsDeepLink: Equatable, Sendable {
/// Custom URL scheme registered in `Info.plist` (`CFBundleURLTypes`).
static let scheme = "wolfwave"
/// The only host this parser understands.
static let host = "settings"

var pane: SettingsView.SettingsSection
/// Kebab-case anchor declared at a `.deepLinkSection(_:)` call site.
var section: String?

init(pane: SettingsView.SettingsSection, section: String? = nil) {
self.pane = pane
self.section = section
}

static let general = SettingsDeepLink(pane: .general)

// MARK: - Parse

/// Resolves a URL to a destination. Falls back to `.general` on any
/// scheme, host, or pane mismatch.
static func parse(_ url: URL) -> SettingsDeepLink {
guard url.scheme?.lowercased() == scheme,
url.host()?.lowercased() == host
else { return .general }

let parts = url.pathComponents.filter { $0 != "/" && !$0.isEmpty }
guard !parts.isEmpty else { return .general }
guard parts.count <= 2 else { return .general }
guard let pane = SettingsView.SettingsSection(slug: parts[0]) else { return .general }

var section: String?
if parts.count == 2 {
let candidate = parts[1].lowercased()
// ponytail: section existence is not validated here; an unknown
// anchor simply has nothing to scroll to and lands on the pane.
section = isValidSectionSlug(candidate) ? candidate : nil
}
return SettingsDeepLink(pane: pane, section: section)
}

/// Inverse of `parse` for the cases `parse` accepts, as a string so a
/// caller never has to handle an impossible `nil`.
var urlString: String {
"\(Self.scheme)://\(Self.host)/" + pane.slug + (section.map { "/" + $0 } ?? "")
}

/// Section slugs are `[a-z0-9-]+`; anything else is refused so a URL can
/// never smuggle odd characters into an anchor id.
static func isValidSectionSlug(_ slug: String) -> Bool {
!slug.isEmpty && slug.unicodeScalars.allSatisfy {
($0 >= "a" && $0 <= "z") || ($0 >= "0" && $0 <= "9") || $0 == "-"
}
}
}
Loading