fix(environment): cache App Store environment checks (#254)

`+[GULAppEnvironmentUtil isAppStoreReceiptSandbox]` read
`-[NSBundle appStoreReceiptURL]` on every call. That property makes a
synchronous XPC call to the LaunchServices daemon, which can block the
calling thread for seconds when the daemon is busy. `+isFromAppStore`
repeated the call because it only cached `IsAppEncrypted()`. Firebase
calls these from the main thread (heartbeat user agent, Messaging APNs
token handling), which caused hangs and watchdog terminations.

Compute both values once per process with `dispatch_once`. Their inputs
(receipt type, bundle contents, Mach-O encryption info, and the
Info.plist opt-out key) can't change while the app is running. The
original bodies move unchanged into file-local helpers.

Part of firebase/firebase-ios-sdk#16726
This commit is contained in:
Paul Beusterien authored and GitHub committed 2026-10-13 11:56:14 -04:00
1 parent cc31f5ed46
commit 34314b8f75
3 files changed
+71 -10

No files matched your search

+7
View File
@@ -1,3 +1,10 @@
# Unreleased
- [fixed] `GULAppEnvironmentUtil` now computes `isAppStoreReceiptSandbox` and
`isFromAppStore` once per process and caches the results. Previously every
call read `-[NSBundle appStoreReceiptURL]`, which makes a synchronous XPC call
to the LaunchServices daemon and could block the calling thread, often the
main thread, for seconds. (firebase/firebase-ios-sdk#16726)
# 8.1.4
- [fixed] `GULMutableDictionary` now ignores a `nil` key (and a `nil` object in
`setObject:forKey:`) instead of throwing on its internal queue, which crashed
@@ -64,14 +64,7 @@ static BOOL HasEmbeddedMobileProvision(void) {
#endif
}
+ (BOOL)isFromAppStore {
static dispatch_once_t isEncryptedOnce;
static BOOL isEncrypted = NO;
dispatch_once(&isEncryptedOnce, ^{
isEncrypted = IsAppEncrypted();
});
static BOOL IsFromAppStore(void) {
if ([GULAppEnvironmentUtil isSimulator]) {
return NO;
}
@@ -93,10 +86,10 @@ static BOOL HasEmbeddedMobileProvision(void) {
// the iTunesMetadata.plist outside of the sandbox will be rejected by Apple.
// If the app does not contain the embedded.mobileprovision which is stripped out by Apple when
// the app is submitted to store, then it is highly likely that it is from Apple Store.
return isEncrypted && !HasEmbeddedMobileProvision();
return IsAppEncrypted() && !HasEmbeddedMobileProvision();
}
+ (BOOL)isAppStoreReceiptSandbox {
static BOOL IsAppStoreReceiptSandbox(void) {
// Since checking the App Store's receipt URL can be memory intensive, check the option in the
// Info.plist if developers opted out of this check.
id enableSandboxCheck =
@@ -111,6 +104,34 @@ static BOOL HasEmbeddedMobileProvision(void) {
return [appStoreReceiptFileName isEqualToString:kFIRAIdentitySandboxReceiptFileName];
}
+ (BOOL)isFromAppStore {
// The inputs (install source and bundle contents) can't change while the app is running, so
// compute the result once.
static dispatch_once_t isFromAppStoreOnce;
static BOOL isFromAppStore = NO;
dispatch_once(&isFromAppStoreOnce, ^{
isFromAppStore = IsFromAppStore();
});
return isFromAppStore;
}
+ (BOOL)isAppStoreReceiptSandbox {
// `-[NSBundle appStoreReceiptURL]` makes a synchronous XPC call to the LaunchServices daemon,
// which can block the calling thread for seconds when the daemon is busy. The receipt can't
// change while the app is running, so compute the result once.
// See https://github.com/firebase/firebase-ios-sdk/issues/16726.
static dispatch_once_t isAppStoreReceiptSandboxOnce;
static BOOL isAppStoreReceiptSandbox = NO;
dispatch_once(&isAppStoreReceiptSandboxOnce, ^{
isAppStoreReceiptSandbox = IsAppStoreReceiptSandbox();
});
return isAppStoreReceiptSandbox;
}
+ (BOOL)isSimulator {
#if TARGET_OS_SIMULATOR
return YES;
@@ -20,6 +20,8 @@
#endif
#import "GoogleUtilities/Environment/Public/GoogleUtilities/GULAppEnvironmentUtil.h"
#import "GoogleUtilities/MethodSwizzler/Public/GoogleUtilities/GULSwizzler.h"
#import "GoogleUtilities/SwizzlerTestHelpers/Public/GoogleUtilities/GULSwizzler+Unswizzle.h"
@interface GULAppEnvironmentUtil (Tests)
+ (void)setProcessInfoForTest:(NSProcessInfo *)processInfo;
@@ -159,4 +161,35 @@
XCTAssertEqualObjects([GULAppEnvironmentUtil appleDevicePlatform], expectedPlatform);
}
- (void)testAppStoreReceiptURLIsReadOnlyOnce {
// Warm up the cached values.
BOOL isAppStoreReceiptSandbox = [GULAppEnvironmentUtil isAppStoreReceiptSandbox];
BOOL isFromAppStore = [GULAppEnvironmentUtil isFromAppStore];
__block NSInteger receiptURLReadCount = 0;
[GULSwizzler swizzleClass:[NSBundle class]
selector:@selector(appStoreReceiptURL)
isClassSelector:NO
withBlock:^NSURL *(id bundle) {
receiptURLReadCount++;
return nil;
}];
// Sanity check that reads of the receipt URL are intercepted.
XCTAssertNil([NSBundle mainBundle].appStoreReceiptURL);
XCTAssertEqual(receiptURLReadCount, 1);
receiptURLReadCount = 0;
// `-[NSBundle appStoreReceiptURL]` makes a synchronous XPC call that can block for seconds, so
// later calls must return the cached values without reading it again.
XCTAssertEqual([GULAppEnvironmentUtil isAppStoreReceiptSandbox], isAppStoreReceiptSandbox);
XCTAssertEqual([GULAppEnvironmentUtil isFromAppStore], isFromAppStore);
[GULSwizzler unswizzleClass:[NSBundle class]
selector:@selector(appStoreReceiptURL)
isClassSelector:NO];
XCTAssertEqual(receiptURLReadCount, 0);
}
@end