A week before BlinkFi went to the App Store, I realised nobody had ever bought it. Not even me, not even in the simulator.

The paywall had been written against a hard-coded fallback. When StoreKit has no product to offer, the button says "Unlock Pro, $4.99 lifetime" anyway, so the screen looked finished. The project had no StoreKit configuration file, which meant the simulator had never once loaded the real product behind that button.

Time to add one and watch an actual purchase go through.

The file

A .storekit file is just JSON. One non-consumable, family shareable, 4.99 USD:

{
  "products" : [
    {
      "displayPrice" : "4.99",
      "familyShareable" : true,
      "internalID" : "6E7F8A9B-0C1D-4E2F-A3B4-C5D6E7F8A9B0",
      "localizations" : [
        {
          "description" : "Unlimited scans and full network history",
          "displayName" : "BlinkFi Pro",
          "locale" : "en_US"
        }
      ],
      "productID" : "dev.ddz.blinkfi.pro",
      "referenceName" : "BlinkFi Pro",
      "type" : "NonConsumable"
    }
  ],
  "settings" : { "_locale" : "en_US", "_storefront" : "USA" },
  "version" : { "major" : 4, "minor" : 0 }
}

The project is generated with XcodeGen (2.45.3), so the file is wired into the scheme in project.yml rather than in Xcode's scheme editor. SnapConnect is the app's working name from before it had a real one:

targets:
  SnapConnect:
    scheme:
      storeKitConfiguration: SnapConnect/Resources/BlinkFi.storekit

Regenerate, and the shared .xcscheme gets its StoreKitConfigurationFileReference. So far, so documented.

It's in the app bundle now

First surprise, and a small one. The .storekit file lives under the app target's source folder, and XcodeGen maps that extension to the resources build phase. The regenerated pbxproj had this:

7FCC02FCF25C657B82328342 /* BlinkFi.storekit in Resources */ = {isa = PBXBuildFile; ...

The scheme option doesn't need the file inside the app. It's a development-time thing. Shipping it isn't dangerous, but it's a few kilobytes of "here's how I test purchases" sitting in every customer's download for no reason.

XcodeGen has a per-extension override for exactly this:

options:
  fileTypes:
    storekit:
      buildPhase: none

Regenerate again, the file reference stays in the group so you can open it in Xcode, and the build phase entry is gone. The one place that does want it bundled is the unit test target, because SKTestSession(configurationFileNamed:) looks the file up in a bundle. There it goes in explicitly:

  SnapConnectTests:
    sources:
      - path: SnapConnectTests
      - path: SnapConnect/Resources/BlinkFi.storekit
        buildPhase: resources

The build works and nothing happens

Build for the simulator with xcodebuild, install and launch the .app with simctl, open Settings, tap Upgrade to Pro.

The paywall says "Unlock Pro, $4.99 lifetime".

Which proves nothing, because that's also the fallback text. The price in the StoreKit file is $4.99 and the hard-coded price is $4.99. I had built a test whose success and failure looked identical. Well done.

The tie-breaker is the button. If the product loaded, tapping it calls product.purchase() and the StoreKit test environment presents its payment sheet. If the product is nil, the purchase method bails out early and the tap does nothing at all.

I tapped. Nothing at all.

Why simctl doesn't count

The StoreKit configuration isn't a build setting, and the app doesn't go looking for the file. It's an option on the scheme's Run action. When Xcode runs the app, it switches on the local StoreKit test environment with the products from that file. xcrun simctl launch starts the same binary without going anywhere near the Run action, so nothing switches it on, the product never loads, and the fallback text does its job of hiding that from you.

I knew this in the vague way you know things you've read once. It took a tap that did nothing to make it concrete.

So for a manual run, Xcode has to do the launching. I wanted that scriptable, because the whole point of the exercise was to repeat it without clicking around, and Xcode's AppleScript dictionary has a debug command that takes a scheme name and an xcodebuild-style destination:

tell application "Xcode"
  set wd to first workspace document whose path is ¬
    "/Users/me/code/snapconnect/SnapConnect.xcodeproj"
  set r to debug wd scheme "SnapConnect" ¬
    run destination specifier "platform=iOS Simulator,name=iPhone 18 Pro"
  -- poll (status of r) for failures
end tell

open -a Xcode SnapConnect.xcodeproj first, wait for loaded of wd to be true, then send that. Match the document by its full path. I had the same project open from two checkouts, and whose path contains "snapconnect" is happy to pick either. Xcode builds into its own DerivedData, installs, attaches the debugger, and launches. Add skip building true on later runs to go straight to launch.

One trap: the status reads running as soon as the scheme action starts, while Swift is still compiling. It tells you the action is in progress, not that the app is up. For that, ask the simulator:

xcrun simctl spawn booted launchctl list | grep UIKitApplication:dev.ddz.blinkfi

Two and a half minutes of white

Xcode reported the action as running. The simulator showed a white screen. Thirty seconds. A minute. Two minutes. Still white.

ps said the process was alive, state S, zero CPU. The unified log had the app registering camera devices and then nothing. I was about to decide the debugger had deadlocked it when sample gave me something to look at:

sample 46512 2 -file sample.txt

Main thread, bottom of the stack:

SwiftUI first commit → CoreGraphics colour conversion → vImage
  → LoadCGInterfaces → dlopen
    → dyld4::RuntimeState::notifyDebuggerLoad
      → dyld4::ExternallyViewableState::notifyMonitorOfImageListChangesSim

The app's first render called into vImage, vImage did a dlopen, and the main thread was sitting in the part of dyld that tells an attached debugger about newly loaded images. Not in my code, and not in StoreKit. The sample can't say what lldb was doing on the other end. On a fresh DerivedData with a debug build, my guess is it was reading symbols.

About two and a half minutes after launch the scan screen appeared as if nothing had happened. The second launch took seconds.

The payment sheet, finally

Settings, Upgrade to Pro, tap the button. The button turns into a spinner, which already means product was non-nil. A few seconds later:

storekitd  AMSPaymentSheetTask: Presenting payment sheet

and the simulator shows the Xcode test sheet: "BlinkFi Pro", "$4.99, One-time charge", "For testing purposes only. You will not be charged for confirming this purchase." Confirm, and the log reads:

storekitd  Created transaction: [0] b:dev.ddz.blinkfi p:dev.ddz.blinkfi.pro s:1
storekitd  Purchase completed successfully.

Settings flipped from the free plan to a green "BlinkFi Pro". Stop the app, launch it again, and the entitlement check on startup finds the transaction:

storekitd  Querying transaction store currentEntitlements(productID: Optional("dev.ddz.blinkfi.pro"))
storekitd  Query returned 1 transactions

Pro again, no tap required.

One thing I couldn't show in the simulator: Restore Purchases bringing Pro back from a non-Pro state. After I uninstalled and reinstalled the app, the same query returned zero transactions, so there was nothing to restore. And while the transaction exists, the app is already Pro at launch. Restore does run its full dance (a simulated "Sign in with Apple Account" dialog, a sync, a fresh entitlement query), it just has nothing to find. SKTestSession and buyProduct(identifier:) can create the transaction behind the app's back in a unit test, which is why the test target bundles the file. Even there, the test has to call the restore path itself, or the startup entitlement check gets the credit.

Takeaway

A .storekit file is a promise the scheme makes and Xcode keeps at launch, not a thing your app reads. Launch with simctl and the promise isn't made, and a fallback price in the paywall will make sure you don't notice. Give the fallback a different number while you're testing, or better, make the button do something visibly different when the product is missing.

And when the simulator sits on a white screen after you attach a debugger, run sample before you reach for Force Quit.