Druware.MusicKit 0.1.0

MusicKitWeb

Can a C#/.NET WPF app drive MusicKit JS (MusicKit for the Web) inside WebView2, using a developer token minted by the existing Card Server, well enough to replace native MusicKit in the Music Bingo WPF apps?

Two projects answer that. MusicKitWeb.Spike is the throwaway that proved it — one window, nine buttons, and a non-interactive --selftest. Druware.MusicKit is the library the spike became; if you are here to use this, that is the part you want, and its section is at the bottom.

Build and run

dotnet build MusicKitWeb.slnx -c Debug
dotnet test  test\MusicKitWeb.Spike.Tests
$env:MUSICKITWEB_SPIKE_CARD_SERVER = 'https://cards.example.com'
& .\src\MusicKitWeb.Spike\bin\Debug\net10.0-windows10.0.19041.0\MusicKitWeb.Spike.exe

Requires the .NET 10 SDK and the Evergreen WebView2 runtime (Windows 11 ships it). The runtime version is checked at startup and shown on the first status line; if it is missing the app says so and stops rather than failing obscurely.

MUSICKITWEB_SPIKE_CARD_SERVER is the base URL of the Card Server that mints the developer token, and there is no default: a token endpoint is deployment configuration, not something to bake in. Unset, the spike says so and exits 1 rather than starting a window whose every button fails the same way.

What to click, and what to look for

Button What it proves Needs sign-in?
1. Fetch token + configure POST /api/v1/shazam/token returns a token, and MusicKit.configure accepts it. Status line fills in the expiry countdown and the storefront. no
2. Authorize music.authorize() opens Apple's popup (authorize.music.apple.com) in a real WebView2 popup window. After signing in, Authorized: yes. this is the sign-in
3. Search + play Catalog search on the developer token alone; the five hits are logged with ids. If authorized, they are queued and playback starts. search no, play yes
Pause/Resume, Skip, Stop Transport control from .NET, with playbackStateDidChange coming back the other way. yes
Rotate developer token Fetches a second token and tries to hand it to the live instance. See Token rotation below. no
Unauthorize Drops the Music User Token. —

The Now playing line and the State name are driven purely by MusicKit events crossing the bridge, so watching them move is the evidence that events work. Every bridge message, event and error is in the log box at the bottom.

The WebView2 control is deliberately visible (200 px). Apple's own error UI, if any, shows up there.

Sign-in persists across launches: MusicKit stores the Music User Token in the page's localStorage, and the WebView2 user-data folder is persistent.

--selftest

$p = Start-Process .\src\MusicKitWeb.Spike\bin\Debug\net10.0-windows10.0.19041.0\MusicKitWeb.Spike.exe `
     -ArgumentList '--selftest' -Wait -PassThru
$p.ExitCode
Get-Content $env:LOCALAPPDATA\MusicKitWeb.Spike\selftest.log

Shows the window, runs steps 1–7 with a 90 s overall budget, writes a PASS/FAIL line each, and exits: 0 when steps 1–6 passed, 1 when any failed, 2 on timeout. It takes about four seconds and needs network access to the Card Server named by MUSICKITWEB_SPIKE_CARD_SERVER, js-cdn.music.apple.com and api.music.apple.com.

Step 7 (playback) is skipped unless a previous interactive run left the WebView2 profile signed in — that is the one thing an unattended run cannot do for itself.

Logs

Path Contents
%LOCALAPPDATA%\MusicKitWeb.Spike\spike.log Append-only transcript of every run: bridge traffic, events, errors.
%LOCALAPPDATA%\MusicKitWeb.Spike\selftest.log The last self-test report, overwritten each run.
%LOCALAPPDATA%\MusicKitWeb.Spike\WebView2\ WebView2 profile — this is where the Apple sign-in lives. Delete it to sign out completely.

The developer token is held in memory only and never written anywhere. Only its expiry and its length are logged. The Music User Token never crosses the bridge at all: authorize() resolves to it in JS, and only a boolean is posted back.

Findings

Catalog search works on the Card Server's token. The same ES256 Apple Media Services JWT the Shazam endpoint mints is accepted by MusicKit JS as a developerToken, and /v1/catalog/us/search answers. No separate key is needed.

Token rotation: assignment does not work, reconfigure does.

music.developerToken = newToken
  -> TypeError: Cannot set property developerToken of #<MKInstance> which has only a getter
await MusicKit.configure({ developerToken: newToken, app })
  -> returnedInstance=true, storefrontId=us, developerTokenMatches=true

So the rotation path for a long-running app is a second MusicKit.configure(...) with the fresh token. configure returns the same singleton, which is why spike.js guards against attaching its event listeners twice.

Known caveats and open questions

  • Whether the sign-in survives a reconfigure is unverified. The self-test observed isAuthorized=false before and after, because it does not sign in. Click 2. Authorize, then Rotate developer token, and read the isAuthorized in the log line — that is the one answer the automated run cannot give.
  • Playback is untested by the automated run for the same reason, and needs an Apple Music subscription, not just an Apple ID.
  • Autoplay policy. WebView2 is created with --autoplay-policy=no-user-gesture-required, because Chromium otherwise refuses audio that script started rather than a click. Without it, play() resolves but nothing is heard.
  • The page is served from https://musickit.spike/, a virtual host mapped to the output Web folder, not from file://: MusicKit JS needs a secure origin with working localStorage and a real Origin header for Apple's CDN and API.
  • NewWindowRequested is logged but never handled. MusicKit's authorize() uses window.open; suppressing or redirecting the popup breaks sign-in.
  • musickit.js is loaded from Apple's CDN and is never copied into the build. Apple's terms forbid bundling, re-hosting or modifying it.
  • The token endpoint is rate limited (nginx, 2 req/s). Tokens are fetched only on an explicit click or when the held one is inside 60 s of expiry; --selftest fetches exactly two.
  • MusicKit reported version 3.2526.0-prerelease.x from the v3 CDN path. It is Apple's moving target, not a pin, so a future version can change behaviour without anything here changing.

Druware.MusicKit

The library the spike became: Apple Music for a WPF application, shaped like the MusicService the Swift Music Bingo app uses, driven by MusicKit JS in a WebView2 nobody ever sees.

It is app-agnostic — no MVVM framework, no DI container, no knowledge of Music Bingo. Adapting it to MusicBingo.WPF's existing SpotifyService shape is a separate job.

dotnet build MusicKitWeb.slnx -c Debug
dotnet test  test\Druware.MusicKit.Tests

API sketch

using var http = new HttpClient();

await using var music = await MusicKitHost.CreateAsync(new MusicKitOptions
{
    DeveloperTokenProvider = new CardServerDeveloperTokenProvider(
        http, () => "https://cards.example.com"),
    UserDataFolder = Path.Combine(localAppData, "MusicBingo", "WebView2"),
    AppName = "Music Bingo",
});                                     // must be called on a WPF dispatcher thread

music.StorefrontId;                     // "us"
music.AuthorizationStatus;              // NotDetermined | Authorized | NotAuthorized
await music.RequestAuthorizationAsync();// opens Apple's sign-in popup
await music.UnauthorizeAsync();

await music.GetLibraryPlaylistsAsync();                 // all pages
await music.GetLibraryPlaylistAsync(id);                // null when it is gone
await music.GetLibraryPlaylistTracksAsync(playlistId);  // all pages
await music.SearchCatalogSongsAsync("Beatles", 5);      // developer token only, no sign-in
await music.CreateLibraryPlaylistAsync(name, catalogSongIds);

await music.Player.SetQueueAsync(songs);
await music.Player.PlayAsync();
await music.Player.PauseAsync();
await music.Player.SkipToNextAsync();
await music.Player.StopAsync();

music.Player.State;        // MusicPlaybackState
music.Player.CurrentSong;  // the queued MusicSong the now-playing item resolves to
music.Player.StateChanged += ...;
music.Player.CurrentSongChanged += ...;
music.Player.PlaybackError += ...;
music.Diagnostic += ...;   // human-readable log lines; never a token, never a full URI

CurrentSong goes through a three-tier resolver, ported from the Swift app for the same reason it exists there: Apple Music reports the now-playing item by a catalog id for a track queued by library id, and the other way round. Identifier as given, then the catalog/library cross-reference out of playParams, then title and artist.

Every method may be called from any thread; WebView2 work is marshalled to the dispatcher and every event is raised on it. MusicKitHost.CreateAsync is the exception — it must be called on a dispatcher thread and throws InvalidOperationException otherwise.

Hosting model

  • The WebView2 is a CoreWebView2Controller parented to a HwndSource styled WS_POPUP | WS_EX_TOOLWINDOW, positioned off-screen, never shown, not in the taskbar, with IsVisible = false. Verified: audio still comes out of it. The integration test starts an AudioContext in that page and watches it reach running with an advancing clock, so the hidden hosting and Chromium's --autoplay-policy=no-user-gesture-required are both proven, without an Apple account.
  • index.html and musickit-host.js are embedded resources, served from https://musickit.druware.local/ through AddWebResourceRequestedFilter + WebResourceRequested. Content files beside the executable would not survive a ProjectReference; a virtual https origin is still required, because MusicKit JS needs a secure origin with working localStorage and a real Origin header.
  • musickit.js is loaded from Apple's CDN and never bundled, re-hosted or modified.
  • NewWindowRequested is logged (scheme and host only — the popup's query carries the developer token) and left to WebView2's default handling, because authorize() uses window.open.
  • The origin is part of the user's identity. MusicKit keeps the Music User Token in that origin's localStorage, so changing musickit.druware.local signs every user out.

Developer token and rotation

CardServerDeveloperTokenProvider is the mature client ported from Druware.ShazamKit: one unauthenticated POST /api/v1/shazam/token, in-memory cache, 60 s refresh margin, single-flight through a semaphore, and a 429 retried three times with a doubling, jittered backoff. The token is never logged, never persisted, and a failed refresh leaves the working one in place.

MusicKit exposes developerToken as a getter only, so rotation means a second MusicKit.configure. That keeps the sign-in but stops playback and clears the queue — which is why rotation happens only at seams:

When Rotates if the token is inside RotationLeadTime (10 min)
SetQueueAsync always
SkipToNextAsync always — the queue tail is re-applied and played, which sounds identical
PlayAsync unless a track is already playing
idle timer, every 60 s only while stopped, completed, or never started

After a rotation the remaining queue (Queue[CurrentIndex..], or [CurrentIndex+1..] for a skip) is re-applied and the requested action continues. TokenRotationPolicy is a separate, unit-tested type precisely so those rules can be read and asserted rather than inferred from control flow.

Creating a playlist

music.api.music has no POST. Its third argument takes a fetchOptions object, and MusicKit 3.2526 ignores the method in it — the integration test asks a catalog song for a POST and gets 200 with the song's data back, which is a GET. So CreateLibraryPlaylistAsync posts with a plain fetch inside the page, where the Music User Token already is and so where it stays. Re-run that probe before trusting a newer MusicKit.

Integration test

$env:DRUWARE_MUSICKIT_INTEGRATION = '1'
$env:DRUWARE_MUSICKIT_CARD_SERVER = 'https://cards.example.com'
dotnet test test\Druware.MusicKit.Tests --filter "Category=Integration" --logger "console;verbosity=detailed"

DRUWARE_MUSICKIT_CARD_SERVER has no default and the test fails with that sentence when it is unset; the profile and origin overrides (DRUWARE_MUSICKIT_PROFILE, DRUWARE_MUSICKIT_ORIGIN) do fall back to the spike's.

Without the switch it is skipped with a reason. With it, it stands a real host up against the real Card Server and Apple's CDN, asserts the storefront and a catalog search, and runs the two runtime probes above.

The library and playback half needs a signed-in WebView2 profile, which no unattended run can create for itself. It points at %LOCALAPPDATA%\MusicKitWeb.Spike\WebView2 and at the spike's origin, so signing in once through the spike (2. Authorize) is what turns those steps on; until then the test logs NOT AUTHORIZED and stops there rather than pretending.

What is not proven yet

  • Playback through this library. The spike proved playback works in a visible WebView2, and the probe above proves audio works in this hidden one, but the two have not been proven together because the profile this test uses was signed out at the end of the last spike session.
  • Library-only tracks in the queue. SetQueueAsync queues by CatalogId ?? Id with setQueue({songs}) and falls back to setQueue({items}) with explicit library-songs types when the first form queues nothing. The fallback has not been seen to fire.
  • CreateLibraryPlaylistAsync end to end. It would write to a real Apple Music library, so it has deliberately not been run.

License

Two of them, and you pick.

LGPL-2.1-or-later is the default. It costs nothing, needs no agreement with anyone, and is what you are using if you took the package as it is distributed and did nothing else. The full text is in LICENSE.LGPL-2.1.

A commercial license from Druware Software Designs is the alternative, for the case the LGPL does not fit. Section 6 wants a recipient to be able to relink your application against a modified build of this library, and an app that links statically and ships through an app store cannot readily offer that. Nothing about the commercial option narrows the LGPL — if you can satisfy the LGPL, ignore it. For terms, write to support@druware.com.

The whole notice, and which one applies when, is in LICENSE.

Third parties. Apple's MusicKit JS is not included here or shipped in the package: it is loaded at runtime from Apple's CDN and stays subject to Apple's own terms, which this license cannot alter and cannot grant you any rights under — you need your own Apple Developer Program membership. Microsoft.Web.WebView2 is a NuGet dependency under Microsoft's license, not redistributed here.

Apple, Apple Music and MusicKit are trademarks of Apple Inc.; the names appear here only to say what this interoperates with.

No packages depend on Druware.MusicKit.

.NET 10.0

Version Downloads Last updated
0.1.0 22 09/08/2026