The non-obvious things, roughly in order of how much time each cost.
ASDPurchase.purchaseID defaults to 0, and with 0 the purchase succeeds — success = 1, a
placeholder appears on the Home Screen — and then nothing ever downloads. The job sits like this
indefinitely:
bundleID com.netflix.Speedtest
storeItemID 1133348139
failureError (nil) <- no error at all
percentComplete -1 <- never started
phase 9
orderKey (nil) <- no queue position
purchaseID 0
appstored correlates a job back to its purchase through purchaseID. With 0 the job is created but
never scheduled. Nothing fails, so nothing is reported.
Set any nonzero value and the whole pipeline runs to completion.
-[JobManagerListener _externalPhaseForPhase:] (0x10022ba34) maps internal phases to what a
client sees:
internal external
-40 -> 4 15,20 -> 7
-30 -> 0 30 -> 2
-20 -> 3 40,50 -> 5
-15,-10 -> 6
default -> 9 <- everything unmapped lands here
So 9 is a fallback bucket, not a state. +[AppInstallQueue _computedPhaseForCoordinatorState:allowResume:]
(0x100323f28) shows coordinator states 0 and 1 give internal phase 10, which has no entry
above. Phase 9 therefore means the install coordinator exists in its initial state and never
advanced — consistent with orderKey = nil.
com.apple.itunesstored.private — alone. Bisected one entitlement at a time against both the
purchase and purchase-history services:
| entitlement | result |
|---|---|
com.apple.itunesstored.private |
works |
com.apple.appstored.private |
505 |
com.apple.appstored.install-apps |
505 |
com.apple.appstored.update-apps |
505 |
com.apple.appstored.jobmanager |
505 |
com.apple.appstored.coordinate-apps |
505 |
com.apple.appstored.xpc.updates |
505 |
-[StoreQueueListener listener:shouldAcceptNewConnection:] (0x1000712c4) accepts on any of
appstored.private, appstored.install-apps or itunesstored.private — but that is a different
listener. ASDPurchaseManager goes through the service catalog, gated in
-[XPCServiceClient _provideService:forEntitlement:withReply:] (0x100190328), which raises
ASDErrorDomain 505 "Not entitled for this service."
ipsw class-dump renders every completion handler as (id /* block */)handler — no argument types.
Declaring one wrong is not graceful:
EXC_BAD_ACCESS (SIGBUS), (Data Abort) byte read Alignment fault
objc_retain + 8
objc_storeStrong + 44
__main_block_invoke + 56
__NSXPCCONNECTION_IS_CALLING_OUT_TO_REPLY_BLOCK__
ARC emitted objc_storeStrong on an argument slot holding a leftover register value.
Inspecting the slots to find out is also unsafe: a bad pointer raises SIGSEGV, and @try catches
ObjC exceptions, not signals. Two tools crashed that way.
The safe pattern:
- declare the block with no arguments — always valid to invoke, extra registers ignored
- confirm the outcome by re-reading state
To actually discover an arity, build non-ARC, over-declare as four void *, and print the raw
registers. That is how updateWithCompletionHandler: was found to be ^(NSError *) — one argument,
not two.
Calling +[ASDServiceBroker defaultBroker] getPurchaseServiceWithError: while a startPurchase:
is in flight kills it:
NSCocoaErrorDomain 4099
"The connection to service named com.apple.appstored.xpc was interrupted, but the message was sent
over an additional proxy and therefore this proxy has become invalid."
Standing up a second connection invalidates the first one's reply proxy. Fetch the service proxy once, hold it, and send everything over it.
A rejected purchase returns HTTP 200 with a dialog in the payload. Sending a deliberately invalid purchase produced:
dialogId = "MZCommerce.TID.SignatureRequired"
customerMessage = "Sign In to iTunes Store"
okButtonAction.buyParams = "isInApp=false&icloud-backup-enabled=1&clientCorrelationKey=…
&guid=…&hasBeenAuthedForBuy=true&ageCheck=true&ad-networks=%28%20%20%29"
okButtonAction.buyParams is Apple's server telling the client exactly what to resend once the user
agrees. That mechanism is what makes hands-off operation possible — dismiss the sheet, take the
confirmedPaymentUUID it hands back, resend.
For an owned app, appstored can offer the last compatible build through
MZCommerce.ProductRequiresLaterStoreVersion; appstorectl accepts that action automatically. If
Apple has no compatible build, the install fails with the server's message.
An unowned app whose current version requires a newer iOS is rejected before the license is added. Acquire it once on a compatible device, then retry on the older device.
IXPromisedStreamingZipTransfer extracts the archive while it downloads. In the observed installs,
/var/mobile/Media/Downloads/ held only downloads.28.sqlitedb, the queue database, with no
complete payload found during or after installation. Whole-system filesystem tracing was not run.
There was therefore no persisted IPA available to copy in the observed path. export reconstructs
the package from the installed container instead. By default it decrypts the staged images; with
--no-decrypt, the result keeps the genuine FairPlay-encrypted payload (cryptid 1, verified on
the main binary and all five of Opera's app extensions). Neither output is byte-identical to
Apple's: zip ordering differs, the .sinf is this Apple ID's, and the store already thinned the
slice server-side (variantID = 1:iPhone10,3:16).
Timestamps prove where the sinf comes from. On a FAST install, SC_Info/FAST.sinf carries the
install time while .supp, .supf, .supx and Manifest.plist all carry the archive's own date
from 2022. installd writes the sinf per Apple ID at install time; the rest ships in the IPA.
SC_Info/Manifest.plist holds two keys, and on an app with extensions they differ:
SinfPaths => [ SC_Info/Opera.sinf ] 1 entry
SinfReplicationPaths => [ ...5 appex sinfs..., SC_Info/Opera.sinf ] 6 entries
Validating an export against SinfPaths alone passes an archive missing every extension's sinf.
On an app with no extensions the two keys are identical, so this is invisible until a plugin-bearing
app is tested. export takes the union.
com.apple.itunesstored holds both BiometricState and BiometricStateEnabled. Measured on
16.7.12:
| wrote | -biometricState |
-isBiometricStateEnabled |
|---|---|---|
BiometricState = 0 only |
0 |
YES |
both keys = 0 |
0 |
NO |
-[ISBiometricStore setBiometricState:] is the only public setter and it writes the first key;
-isBiometricStateEnabled, which gates the biometric branch, reads the second. Nothing public
writes it, only the Settings opt-out via ISBiometricOptInOperation _updateTouchIDSettingsForAccount:.
Note docs/GATES.md records isBiometricStateEnabled as biometricState == 2 from the 15.8.1
decompilation at 0x1ba753a78. That may still hold there; the table above is a live read on 16.7.12.
Two files exist, and only one matters:
/var/root/Library/Preferences/com.apple.itunesstored.plist 5 keys, created by our own tools
/var/mobile/Library/Preferences/com.apple.itunesstored.plist 90 keys, what the daemons read
appstored, itunesstored and installcoordinationd all run as mobile. A root process reading
BiometricState finds the key absent, returns 0, and reports a clean bill of health for a domain
no daemon consults. This silently inverted a diagnosis until caught. Anything touching these prefs
must run as uid 501.
The purchase session is unaffected by this: it belongs to appstored and resolves identically from any uid. Only in-process preference reads are per-uid.
+[SSAccountStore isExpiredForTokenType:] is now > LastAuthTime + 900.0 (the interval is a bare
ldr d0 of the double at 0x192ba3d28), and a missing LastAuthTime counts as expired.
Observed during a successful purchase: isExpired YES for token types 0, 1 and 2, with
LastAuthTime 32 hours stale. resetExpirationForTokenType: is what writes that key, and the AMS
payment-sheet auth path never calls it. So the client sits permanently "expired" while purchases
work fine on a separate, server-side clock. Do not use it as a health signal.
ASDPurchaseResult.itemIDis nil even on success. Checksuccess, then verify the install separately.notificationCenter:receivedProgress:delivers an NSArray ofASDProgress, despite the singular selector name.bagKey = "downloadProduct"is the accountless path, not a redownload. It routes todownloaddispatch.itunes.apple.com/r/accountlessand fails with13014 MZFinance.DownloadProductRequestParseError. For a redownload of an owned item, stay onbuyProduct.- Binaries must live under
/var/jb. On palera1n rootless a binary in/tmpis SIGKILLed even ad-hoc signed — hello-world included. The identical file under/var/jb/tmp/runs. ASDJobManagerlists in-flight jobs only. A 1 MB app can finish inside the first poll interval. Use the bundle on disk as ground truth for completion, andASDNotificationCenterProgressObserverfor progress.- The container directory name need not match the app. Firefox installs to
Client.app. Resolve installs throughiTunesMetadata.plist'ssoftwareVersionBundleId, not the path. iTunesMetadata.plistis written at placeholder-creation time, so its presence proves nothing about the payload. A real install has the executable andSC_Info/<App>.sinf.