Skip to content
Development

A subscribe button, a MusicKit API I'd never used, and a sheet that dismisses into silence

By Victor Da Luz
swiftmusickitiosdev-logdeep-cut-atlas

Deep Cut Atlas got hit with an App Review rejection over raw MusicKit errors leaking onto screen when someone’s authorized but not actually subscribed to Apple Music. I fixed the crash-adjacent part of that already: a real gate view instead of a stack trace. But the gate itself was rough. Both “you need a subscription” and “you have a subscription, just go accept the terms once” states showed the exact same title, “Apple Music Required” - which is just wrong for the second case. And the not-subscribed state’s only action was a plain text link that said “Try Again.” Try what again? There was no path to actually fix the problem from inside the app.

This was the follow-up: retitle the confusing state, and give the not-subscribed state a real subscribe button.

Reading the framework instead of guessing

MusicKit ships a SwiftUI modifier called musicSubscriptionOffer(isPresented:options:onLoadCompletion:) that presents Apple’s own native “subscribe to Apple Music” sheet, no custom UI required. I hadn’t used it before, so instead of guessing at the signature from half-remembered docs, I read it straight out of the framework:

find /Applications/Xcode.app/.../iPhoneSimulator.sdk -iname "*MusicKit*"

which turned up a whole second framework I didn’t know existed: _MusicKit_SwiftUI.framework. Its .swiftinterface file had the real signature, plus an Options struct with affiliateToken/campaignToken fields I decided not to touch since nothing in this codebase has ever set one up. Grepping a framework’s own compiled interface beats trusting my memory of an API I’ve seen mentioned twice.

The bug that would’ve shipped if I hadn’t looked twice

Here’s the part that actually mattered. musicSubscriptionOffer presents an in-app sheet. It doesn’t background your app or trigger any scene-phase transition. Meanwhile, this app’s subscription-state check only ever re-runs on app launch or on returning from the background. Put those two facts together: someone taps “Get Apple Music,” subscribes in the native sheet, dismisses it… and sees the exact same “Apple Music Required” screen, because nothing told the app to check again. The fix works perfectly for someone who backgrounds and re-opens the app, and is a dead end for someone who doesn’t.

That’s a broken conversion path, not a cosmetic gap. Caught it before writing a line of the fix, by walking through what happens when the sheet closes: nothing scenePhase-shaped was ever going to fire, so something else had to.

.onChange(of: showingSubscriptionOffer) { wasPresented, isPresented in
    if wasPresented && !isPresented {
        Task { await onRetry() }
    }
}

Re-checks the instant the sheet closes, subscribed or not. Cheap, and it means the button’s promise (“get Apple Music, come back subscribed”) is actually true.

A live check I almost couldn’t run

musicSubscriptionOffer talks to real MusicKit, which means it’s dead in the simulator - same as everything else MusicKit in this app. So the only way to actually confirm the button did what it claimed was a real device pass, tapping the actual button and watching for Apple’s actual sheet. On the spare test iPhone 13, the sheet came up and said “You are already an Apple Music subscriber” - which is exactly what a real subscriber account should see, and confirmed the call was hitting Apple’s live subscription service rather than some local mock. Closing it triggered the refresh cleanly, no crash, no hang.

I can’t test what a genuinely unsubscribed account sees end-to-end without one, which is a known gap carried over from the original rejection fix. But “does the button present the real thing and does closing it behave” is now a confirmed yes, not an assumption.

A smaller find: setting simulator language without touching Settings

Verifying the new Spanish strings meant switching the simulator to es-419, and I’d previously documented that per-app language overrides don’t work on this Xcode/iOS combo - only the full Settings app UI flow did. Turns out there’s a faster CLI path I hadn’t tried: writing the global preference domain (not a per-app one) and rebooting the simulator:

xcrun simctl spawn <udid> defaults write -g AppleLanguages -array "es-419" "en"
xcrun simctl spawn <udid> defaults write -g AppleLocale -string "es_419"
xcrun simctl shutdown <udid> && xcrun simctl boot <udid>

Worked on the first try. Small thing, but it turns a “navigate three Settings screens and wait for a respring” step into two shell commands.

Reflection

The interesting part of this issue wasn’t the button, it was noticing that a correctly-coded UI action can still be a functional dead end if nothing downstream reacts to it. “The sheet presents” and “the app knows the sheet closed” are two separate claims, and only checking the first one would’ve shipped a subscribe button that silently doesn’t work for half its users.

Related reading