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=falsebefore and after, because it does not sign in. Click 2. Authorize, then Rotate developer token, and read theisAuthorizedin 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 outputWebfolder, not fromfile://: MusicKit JS needs a secure origin with workinglocalStorageand a realOriginheader for Apple's CDN and API. NewWindowRequestedis logged but never handled. MusicKit'sauthorize()useswindow.open; suppressing or redirecting the popup breaks sign-in.musickit.jsis 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;
--selftestfetches exactly two. - MusicKit reported version
3.2526.0-prerelease.xfrom thev3CDN 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
CoreWebView2Controllerparented to aHwndSourcestyledWS_POPUP | WS_EX_TOOLWINDOW, positioned off-screen, never shown, not in the taskbar, withIsVisible = false. Verified: audio still comes out of it. The integration test starts anAudioContextin that page and watches it reachrunningwith an advancing clock, so the hidden hosting and Chromium's--autoplay-policy=no-user-gesture-requiredare both proven, without an Apple account. index.htmlandmusickit-host.jsare embedded resources, served fromhttps://musickit.druware.local/throughAddWebResourceRequestedFilter+WebResourceRequested. Content files beside the executable would not survive aProjectReference; a virtual https origin is still required, because MusicKit JS needs a secure origin with workinglocalStorageand a realOriginheader.musickit.jsis loaded from Apple's CDN and never bundled, re-hosted or modified.NewWindowRequestedis logged (scheme and host only — the popup's query carries the developer token) and left to WebView2's default handling, becauseauthorize()useswindow.open.- The origin is part of the user's identity. MusicKit keeps the Music User Token in that
origin's
localStorage, so changingmusickit.druware.localsigns 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.
SetQueueAsyncqueues byCatalogId ?? IdwithsetQueue({songs})and falls back tosetQueue({items})with explicitlibrary-songstypes when the first form queues nothing. The fallback has not been seen to fire. CreateLibraryPlaylistAsyncend 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
- Microsoft.Web.WebView2 (>= 1.0.4191.47)
| Version | Downloads | Last updated |
|---|---|---|
| 0.1.0 | 21 | 09/08/2026 |