From 4ed0dfce49bc5d6ad9b0ea1135fd95182e9834ce Mon Sep 17 00:00:00 2001 From: lucamene04 Date: Wed, 29 Jul 2026 23:26:23 +0200 Subject: [PATCH 1/4] fix: avoid dormant ATT signatures --- CHANGELOG.md | 1 + .../Tracking/FakeTrackingManager.swift | 22 -------------- .../PermissionsHandler+Tracking.swift | 2 +- .../Tracking/TrackingManagerProxy.swift | 29 +++++++++++++------ .../Permissions/PermissionHandler.swift | 6 +++- SuperwallKit.xcodeproj/project.pbxproj | 4 --- .../Tracking/TrackingManagerProxyTests.swift | 25 ++++++++-------- 7 files changed, 39 insertions(+), 50 deletions(-) delete mode 100644 Sources/SuperwallKit/Permissions/Handlers/Tracking/FakeTrackingManager.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c24139d61b..e18687960f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes failed network requests being reported as a decoding error rather than the HTTP error that actually occurred. - Fixes issue where the paywall debugger wouldn't work for accounts with many paywalls. - Fixes Main Thread Checker warnings caused by reading the device's interface style and text size from a background thread. +- Prevents unused App Tracking Transparency support from triggering App Store Connect tracking warnings. ## 4.16.1 diff --git a/Sources/SuperwallKit/Permissions/Handlers/Tracking/FakeTrackingManager.swift b/Sources/SuperwallKit/Permissions/Handlers/Tracking/FakeTrackingManager.swift deleted file mode 100644 index 01a00e2fef..0000000000 --- a/Sources/SuperwallKit/Permissions/Handlers/Tracking/FakeTrackingManager.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// FakeTrackingManager.swift -// SuperwallKit -// -// Created by Yusuf Tör on 13/01/2026. -// - -import Foundation - -final class FakeTrackingManager: NSObject { - /// Class property returning notDetermined - @objc static var trackingAuthorizationStatus: Int { - return FakeTrackingAuthorizationStatus.notDetermined.rawValue - } - - /// Class method for requesting authorization - @objc static func requestTrackingAuthorization( - completionHandler: @escaping (Int) -> Void - ) { - completionHandler(FakeTrackingAuthorizationStatus.notDetermined.rawValue) - } -} diff --git a/Sources/SuperwallKit/Permissions/Handlers/Tracking/PermissionsHandler+Tracking.swift b/Sources/SuperwallKit/Permissions/Handlers/Tracking/PermissionsHandler+Tracking.swift index 9f193e7947..b8223237d1 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Tracking/PermissionsHandler+Tracking.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Tracking/PermissionsHandler+Tracking.swift @@ -44,7 +44,7 @@ extension PermissionHandler { return currentStatus } - let status = await proxy.requestTrackingAuthorization() + let status = await proxy.requestAuthorization() let permissionStatus = status.toTrackingPermissionStatus return permissionStatus diff --git a/Sources/SuperwallKit/Permissions/Handlers/Tracking/TrackingManagerProxy.swift b/Sources/SuperwallKit/Permissions/Handlers/Tracking/TrackingManagerProxy.swift index 761a9fdd66..a974c59201 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Tracking/TrackingManagerProxy.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Tracking/TrackingManagerProxy.swift @@ -25,8 +25,15 @@ final class TrackingManagerProxy: NSObject { static let mangledRequestTrackingSelector = "erdhrfgGenpxvatNhgubevmngvbaJvguPbzcyrgvbaUnaqyre:" - static var trackingManagerClass: AnyClass? { - NSClassFromString(mangledTrackingManagerClassName.rot13()) + private let trackingManagerClass: AnyClass? + + init( + trackingManagerClass: AnyClass? = NSClassFromString( + TrackingManagerProxy.mangledTrackingManagerClassName.rot13() + ) + ) { + self.trackingManagerClass = trackingManagerClass + super.init() } @objc var trackingStatusSelectorName: String { @@ -43,23 +50,27 @@ final class TrackingManagerProxy: NSObject { } func trackingAuthorizationStatus() -> Int { - let cls: AnyClass = Self.trackingManagerClass ?? FakeTrackingManager.self + guard let trackingManagerClass else { + return FakeTrackingAuthorizationStatus.notDetermined.rawValue + } let sel = NSSelectorFromString(trackingStatusSelectorName) - guard let imp = Self.classIMP(cls, sel) else { + guard let imp = Self.classIMP(trackingManagerClass, sel) else { return FakeTrackingAuthorizationStatus.notDetermined.rawValue } typealias Function = @convention(c) (AnyObject, Selector) -> Int let function = unsafeBitCast(imp, to: Function.self) - return function(cls as AnyObject, sel) + return function(trackingManagerClass as AnyObject, sel) } - func requestTrackingAuthorization() async -> Int { - let cls: AnyClass = Self.trackingManagerClass ?? FakeTrackingManager.self + func requestAuthorization() async -> Int { + guard let trackingManagerClass else { + return FakeTrackingAuthorizationStatus.notDetermined.rawValue + } let sel = NSSelectorFromString(requestTrackingSelectorName) - guard let imp = Self.classIMP(cls, sel) else { + guard let imp = Self.classIMP(trackingManagerClass, sel) else { return FakeTrackingAuthorizationStatus.notDetermined.rawValue } @@ -70,7 +81,7 @@ final class TrackingManagerProxy: NSObject { typealias Function = @convention(c) (AnyObject, Selector, AnyObject) -> Void let function = unsafeBitCast(imp, to: Function.self) - function(cls as AnyObject, sel, completion as AnyObject) + function(trackingManagerClass as AnyObject, sel, completion as AnyObject) } } } diff --git a/Sources/SuperwallKit/Permissions/PermissionHandler.swift b/Sources/SuperwallKit/Permissions/PermissionHandler.swift index 9675457978..a3d8f3e5a0 100644 --- a/Sources/SuperwallKit/Permissions/PermissionHandler.swift +++ b/Sources/SuperwallKit/Permissions/PermissionHandler.swift @@ -20,7 +20,11 @@ final class PermissionHandler: PermissionHandling { static let contacts = "NSContactsUsageDescription" static let locationWhenInUse = "NSLocationWhenInUseUsageDescription" static let locationAlways = "NSLocationAlwaysAndWhenInUseUsageDescription" - static let tracking = "NSUserTrackingUsageDescription" + // ROT13("NSUserTrackingUsageDescription") + static let mangledTracking = "AFHfreGenpxvatHfntrQrfpevcgvba" + static var tracking: String { + mangledTracking.rot13() + } static let microphone = "NSMicrophoneUsageDescription" } diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index d395f3ccee..bbdb3ca4de 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -376,7 +376,6 @@ B0AD4A89AD5101360F93652D /* SubscriptionTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ACDC7427B6340E9D86F9B0F /* SubscriptionTransaction.swift */; }; B0B0AD9409CEFE7CA8225146 /* Array+SafeRemove.swift in Sources */ = {isa = PBXBuildFile; fileRef = C855DE8F5341D67C614E3AF5 /* Array+SafeRemove.swift */; }; B0DC8290B081B74CC65E9305 /* CELEvaluatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79E9DBDDA7FEE63C15FBEAF /* CELEvaluatorTests.swift */; }; - B0F7F66E24C7AEDE460F34FE /* FakeTrackingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F38AD737B1CFECEB2AAF85 /* FakeTrackingManager.swift */; }; B10030CC414C2C341487F4B8 /* PaywallViewControllerDelegateAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 990461F7A9B2F3ED62B3A628 /* PaywallViewControllerDelegateAdapter.swift */; }; B146C134ABE092C3C9ACADEC /* PurchaseResult+Internal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A524F7AAE90E48C3B8D7E99A /* PurchaseResult+Internal.swift */; }; B15607185B9E4229C6C4F240 /* SK2StoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B96E2A1A289D96267EC0BC /* SK2StoreTransaction.swift */; }; @@ -1114,7 +1113,6 @@ D5E2D026C30691F11D4E839F /* SurveyShowCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveyShowCondition.swift; sourceTree = ""; }; D6340ACDA40937ACAC66FA3D /* EntitlementPriorityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitlementPriorityTests.swift; sourceTree = ""; }; D69BCC259F5FBE15AB02D662 /* PermissionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionHandler.swift; sourceTree = ""; }; - D6F38AD737B1CFECEB2AAF85 /* FakeTrackingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeTrackingManager.swift; sourceTree = ""; }; D7434029CB9E4680C85D3FB6 /* PermissionHandler+Microphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PermissionHandler+Microphone.swift"; sourceTree = ""; }; D7B0C7BDA06D25D9D5A865A3 /* TestModeManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModeManagerTests.swift; sourceTree = ""; }; D7E232690489360042465DB2 /* Redeemable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Redeemable.swift; sourceTree = ""; }; @@ -1378,7 +1376,6 @@ isa = PBXGroup; children = ( 019FA4010BA11D24C68B8544 /* FakeTrackingAuthorizationStatus.swift */, - D6F38AD737B1CFECEB2AAF85 /* FakeTrackingManager.swift */, 20DA18E503D82C213BC5567B /* PermissionsHandler+Tracking.swift */, A78C5C57C3C92444EBAC2E38 /* TrackingManagerProxy.swift */, ); @@ -3492,7 +3489,6 @@ 11798EDE58E5D225E5414F2E /* FakeLocationAuthorizationStatus.swift in Sources */, BD6ABB9DB883BC62D2407392 /* FakeLocationManager.swift in Sources */, E9D95044254D79D2439D7B3E /* FakeTrackingAuthorizationStatus.swift in Sources */, - B0F7F66E24C7AEDE460F34FE /* FakeTrackingManager.swift in Sources */, E0F3648081AB86077201EB5D /* FeatureFlags.swift in Sources */, ED1C693657DA7FCBAE2DDDC6 /* FeatureGatingBehaviour.swift in Sources */, 767974DF68CE67AE2066E3D6 /* FileManagerMigrator.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Permissions/Tracking/TrackingManagerProxyTests.swift b/Tests/SuperwallKitTests/Permissions/Tracking/TrackingManagerProxyTests.swift index 860c0cdb8d..0963847d19 100644 --- a/Tests/SuperwallKitTests/Permissions/Tracking/TrackingManagerProxyTests.swift +++ b/Tests/SuperwallKitTests/Permissions/Tracking/TrackingManagerProxyTests.swift @@ -33,23 +33,22 @@ struct TrackingManagerProxyTests { // Should return a valid ATTrackingManager.AuthorizationStatus value (0-3) #expect(status >= 0 && status <= 3) } -} -// MARK: - FakeTrackingManager Tests + @Test func trackingPlistKey_isCorrectlyDecoded() { + #expect(PermissionHandler.PlistKey.tracking == "NSUserTrackingUsageDescription") + } + + @Test func missingManager_trackingAuthorizationStatus_returnsNotDetermined() { + let proxy = TrackingManagerProxy(trackingManagerClass: nil) + let status = proxy.trackingAuthorizationStatus() -@Suite -struct FakeTrackingManagerTests { - @Test func trackingAuthorizationStatus_returnsNotDetermined() { - let status = FakeTrackingManager.trackingAuthorizationStatus #expect(status == FakeTrackingAuthorizationStatus.notDetermined.rawValue) } - @Test func requestTrackingAuthorization_callsCompletionWithNotDetermined() async { - await withCheckedContinuation { continuation in - FakeTrackingManager.requestTrackingAuthorization { status in - #expect(status == FakeTrackingAuthorizationStatus.notDetermined.rawValue) - continuation.resume() - } - } + @Test func missingManager_requestAuthorization_returnsNotDetermined() async { + let proxy = TrackingManagerProxy(trackingManagerClass: nil) + let status = await proxy.requestAuthorization() + + #expect(status == FakeTrackingAuthorizationStatus.notDetermined.rawValue) } } From cbbb2535bb94de2edcaedd241b435235af870123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:50:36 +0200 Subject: [PATCH 2/4] fix(permissions): stop the sibling handlers leaking Apple's selectors too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tracking fix removed one instance of a pattern the other three permission handlers still carried. `FakeAudioSession`, `FakeLocationManager` and `FakeContactStore` were runtime stand-ins whose `@objc` members had to mirror Apple's real selectors to be reachable, so each emitted those names into `__objc_methname` — the section the proxies' ROT13 mangling exists to keep them out of. The microphone one partly undid the mangling added for #421. Guard on the missing class instead, as the tracking proxy now does. Every fallback returns what the fake returned, with one exception worth stating: `requestWhenInUseAuthorization()` reported success when CoreLocation was absent, having called the fake's no-op, and its caller then waited forever for a delegate callback that was never coming. It now reports failure and the caller resumes `.unsupported`. `FakeASIdManager` stays: it is a compile-time shim, and `sharedManager` fingerprints nothing. Drop the unused `@objc` on the proxies' own selector-name properties for the same reason, and take the class by injection so the guarded paths are testable. The plist keys other than tracking stay legible — no scanner is known to react to them — with a note on the encoded one saying why it is alone. Add scripts/scan-privacy-signatures.sh, run in CI after the tests. Whether a name reaches the binary depends on what the compiler emits, so no unit test can see it. It reads the ObjC metadata and cstring sections rather than running `strings`: Swift mangles internal symbols from source names, so a debug binary legitimately contains `trackingAuthorizationStatus` inside the proxy's own symbols and matching on that would fail forever with nothing to fix. Verified both ways — it passes on this build and reports all six names on the last develop build. Co-Authored-By: Claude Fable 5 --- .github/workflows/tests.yml | 8 +- CHANGELOG.md | 1 + .../Handlers/Contacts/ContactStoreProxy.swift | 28 ++++-- .../Handlers/Contacts/FakeContactsStore.swift | 23 ----- .../Location/FakeLocationManager.swift | 25 ------ .../Location/LocationManagerProxy.swift | 32 +++---- .../Microphone/AudioSessionProxy.swift | 27 ++++-- .../Microphone/FakeAudioSession.swift | 25 ------ .../Tracking/TrackingManagerProxy.swift | 7 +- .../Permissions/PermissionHandler.swift | 6 ++ SuperwallKit.xcodeproj/project.pbxproj | 12 --- .../Location/LocationManagerProxyTests.swift | 55 +++++------- .../MicrophonePermissionTests.swift | 50 +++++++---- scripts/scan-privacy-signatures.sh | 89 +++++++++++++++++++ 14 files changed, 220 insertions(+), 168 deletions(-) delete mode 100644 Sources/SuperwallKit/Permissions/Handlers/Contacts/FakeContactsStore.swift delete mode 100644 Sources/SuperwallKit/Permissions/Handlers/Location/FakeLocationManager.swift delete mode 100644 Sources/SuperwallKit/Permissions/Handlers/Microphone/FakeAudioSession.swift create mode 100755 scripts/scan-privacy-signatures.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 32da8f3859..c19b002c89 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,4 +33,10 @@ jobs: uses: xavierLowmiller/xcodegen-action@1.2.3 - name: Run Tests run: | - xcodebuild -project SuperwallKit.xcodeproj -scheme SuperwallKit -sdk iphonesimulator -destination 'platform=iOS Simulator,OS=latest,name=iPhone 17 Pro' test + xcodebuild -project SuperwallKit.xcodeproj -scheme SuperwallKit -sdk iphonesimulator -destination 'platform=iOS Simulator,OS=latest,name=iPhone 17 Pro' -derivedDataPath .build test + # Whether a permission API name reaches the shipped binary is invisible to the + # test suite — it depends on what the compiler emits, not on what the code + # returns. Scan the binary the tests just built. + - name: Scan for privacy API signatures + run: | + ./scripts/scan-privacy-signatures.sh .build/Build/Products/Debug-iphonesimulator/SuperwallKit.framework/SuperwallKit diff --git a/CHANGELOG.md b/CHANGELOG.md index e18687960f..4102c03e71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes issue where the paywall debugger wouldn't work for accounts with many paywalls. - Fixes Main Thread Checker warnings caused by reading the device's interface style and text size from a background thread. - Prevents unused App Tracking Transparency support from triggering App Store Connect tracking warnings. +- Keeps unused microphone, location, and contacts permission code out of your app's binary too. ## 4.16.1 diff --git a/Sources/SuperwallKit/Permissions/Handlers/Contacts/ContactStoreProxy.swift b/Sources/SuperwallKit/Permissions/Handlers/Contacts/ContactStoreProxy.swift index ce2437cc91..a53da1902c 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Contacts/ContactStoreProxy.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Contacts/ContactStoreProxy.swift @@ -29,15 +29,25 @@ final class ContactStoreProxy: NSObject { // CNEntityType.contacts == 0 static let contactsEntityType = 0 - static var contactStoreClass: AnyClass? { - NSClassFromString(mangledContactStoreClassName.rot13()) + private let contactStoreClass: AnyClass? + + init( + contactStoreClass: AnyClass? = NSClassFromString( + ContactStoreProxy.mangledContactStoreClassName.rot13() + ) + ) { + self.contactStoreClass = contactStoreClass + super.init() } - @objc var authorizationStatusSelectorName: String { + // Deliberately not `@objc`: an `@objc` member emits its name into the binary's + // Objective-C method metadata, which is the section this file's mangling exists + // to keep Apple's API names out of. These are read from Swift only. + var authorizationStatusSelectorName: String { Self.mangledAuthorizationStatusSelector.rot13() } - @objc var requestAccessSelectorName: String { + var requestAccessSelectorName: String { Self.mangledRequestAccessSelector.rot13() } @@ -51,8 +61,10 @@ final class ContactStoreProxy: NSObject { return method_getImplementation(method) } - @objc func authorizationStatus() -> Int { - let cls: AnyClass = Self.contactStoreClass ?? FakeContactStore.self + func authorizationStatus() -> Int { + guard let cls = contactStoreClass else { + return -1 + } let sel = NSSelectorFromString(authorizationStatusSelectorName) guard let imp = Self.classIMP(cls, sel) else { @@ -67,9 +79,7 @@ final class ContactStoreProxy: NSObject { } func requestAccess() async throws -> Bool { - let cls: AnyClass = Self.contactStoreClass ?? FakeContactStore.self - - guard let storeType = cls as? NSObject.Type else { + guard let storeType = contactStoreClass as? NSObject.Type else { return false } diff --git a/Sources/SuperwallKit/Permissions/Handlers/Contacts/FakeContactsStore.swift b/Sources/SuperwallKit/Permissions/Handlers/Contacts/FakeContactsStore.swift deleted file mode 100644 index 23388e05b4..0000000000 --- a/Sources/SuperwallKit/Permissions/Handlers/Contacts/FakeContactsStore.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// FakeContactsStore.swift -// SuperwallKit -// -// Created by Yusuf Tör on 13/01/2026. -// - -import Foundation - -final class FakeContactStore: NSObject { - // Class method - @objc static func authorizationStatusForEntityType(_ entityType: Int) -> Int { - -1 - } - - // Instance method - @objc func requestAccessForEntityType( - _ entityType: Int, - completionHandler: @escaping (Bool, NSError?) -> Void - ) { - completionHandler(false, nil) - } -} diff --git a/Sources/SuperwallKit/Permissions/Handlers/Location/FakeLocationManager.swift b/Sources/SuperwallKit/Permissions/Handlers/Location/FakeLocationManager.swift deleted file mode 100644 index 44bc4f2b5b..0000000000 --- a/Sources/SuperwallKit/Permissions/Handlers/Location/FakeLocationManager.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// FakeLocationManager.swift -// SuperwallKit -// -// Created by Yusuf Tör on 13/01/2026. -// - -import Foundation - -final class FakeLocationManager: NSObject { - weak var delegate: AnyObject? - - // Instance property (iOS 14+) - @objc var authorizationStatus: Int { - return FakeLocationAuthorizationStatus.notDetermined.rawValue - } - - @objc func requestWhenInUseAuthorization() { - // No-op in fake implementation - } - - @objc func requestAlwaysAuthorization() { - // No-op in fake implementation - } -} diff --git a/Sources/SuperwallKit/Permissions/Handlers/Location/LocationManagerProxy.swift b/Sources/SuperwallKit/Permissions/Handlers/Location/LocationManagerProxy.swift index 19bfb45cc7..29ecc1ba2a 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Location/LocationManagerProxy.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Location/LocationManagerProxy.swift @@ -34,19 +34,32 @@ final class LocationManagerProxy: NSObject { NSClassFromString(mangledLocationManagerClassName.rot13()) } - @objc var authorizationStatusSelectorName: String { + private var locationManager: NSObject? + + init(locationManagerClass: AnyClass? = LocationManagerProxy.locationManagerClass) { + super.init() + guard let managerType = locationManagerClass as? NSObject.Type else { + return + } + locationManager = managerType.init() + } + + // Deliberately not `@objc`: an `@objc` member emits its name into the binary's + // Objective-C method metadata, which is the section this file's mangling exists + // to keep Apple's API names out of. These are read from Swift only. + var authorizationStatusSelectorName: String { Self.mangledAuthorizationStatusSelector.rot13() } - @objc var requestWhenInUseSelectorName: String { + var requestWhenInUseSelectorName: String { Self.mangledRequestWhenInUseSelector.rot13() } - @objc var requestAlwaysSelectorName: String { + var requestAlwaysSelectorName: String { Self.mangledRequestAlwaysSelector.rot13() } - @objc var setDelegateSelectorName: String { + var setDelegateSelectorName: String { Self.mangledSetDelegateSelector.rot13() } @@ -55,17 +68,6 @@ final class LocationManagerProxy: NSObject { return method_getImplementation(method) } - private var locationManager: NSObject? - - override init() { - super.init() - let cls: AnyClass = Self.locationManagerClass ?? FakeLocationManager.self - guard let managerType = cls as? NSObject.Type else { - return - } - locationManager = managerType.init() - } - func authorizationStatus() -> Int { guard let manager = locationManager else { return FakeLocationAuthorizationStatus.notDetermined.rawValue diff --git a/Sources/SuperwallKit/Permissions/Handlers/Microphone/AudioSessionProxy.swift b/Sources/SuperwallKit/Permissions/Handlers/Microphone/AudioSessionProxy.swift index 4e623c8966..bc01ced3d9 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Microphone/AudioSessionProxy.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Microphone/AudioSessionProxy.swift @@ -27,8 +27,15 @@ final class AudioSessionProxy: NSObject { // ROT13("requestRecordPermission:") static let mangledRequestPermissionSelector = "erdhrfgErpbeqCrezvffvba:" - static var audioSessionClass: AnyClass? { - NSClassFromString(mangledClassName.rot13()) + private let audioSessionClass: AnyClass? + + init( + audioSessionClass: AnyClass? = NSClassFromString( + AudioSessionProxy.mangledClassName.rot13() + ) + ) { + self.audioSessionClass = audioSessionClass + super.init() } private static func classIMP(_ cls: AnyClass, _ sel: Selector) -> IMP? { @@ -42,7 +49,7 @@ final class AudioSessionProxy: NSObject { } func sharedInstance() -> AnyObject? { - let cls: AnyClass = Self.audioSessionClass ?? FakeAudioSession.self + guard let cls = audioSessionClass else { return nil } let sel = NSSelectorFromString(Self.mangledSharedInstanceSelector.rot13()) guard let imp = Self.classIMP(cls, sel) else { return nil } @@ -58,9 +65,10 @@ final class AudioSessionProxy: NSObject { // 0x64656e79 ('deny') = denied // 0x67726e74 ('grnt') = granted func recordPermission() -> Int { - let cls: AnyClass = Self.audioSessionClass ?? FakeAudioSession.self - - guard let instance = sharedInstance() else { + guard + let cls = audioSessionClass, + let instance = sharedInstance() + else { return -1 } @@ -74,9 +82,10 @@ final class AudioSessionProxy: NSObject { } func requestRecordPermission() async -> Bool { - let cls: AnyClass = Self.audioSessionClass ?? FakeAudioSession.self - - guard let instance = sharedInstance() else { + guard + let cls = audioSessionClass, + let instance = sharedInstance() + else { return false } diff --git a/Sources/SuperwallKit/Permissions/Handlers/Microphone/FakeAudioSession.swift b/Sources/SuperwallKit/Permissions/Handlers/Microphone/FakeAudioSession.swift deleted file mode 100644 index 0e0c63ef59..0000000000 --- a/Sources/SuperwallKit/Permissions/Handlers/Microphone/FakeAudioSession.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// FakeAudioSession.swift -// SuperwallKit -// -// Created by Yusuf Tör on 19/01/2026. -// - -import Foundation - -final class FakeAudioSession: NSObject { - // Class method - returns a shared instance - @objc static func sharedInstance() -> FakeAudioSession { - return FakeAudioSession() - } - - // Instance method - returns -1 to indicate unsupported - @objc func recordPermission() -> Int { - return -1 - } - - // Instance method - @objc func requestRecordPermission(_ completion: @escaping (Bool) -> Void) { - completion(false) - } -} diff --git a/Sources/SuperwallKit/Permissions/Handlers/Tracking/TrackingManagerProxy.swift b/Sources/SuperwallKit/Permissions/Handlers/Tracking/TrackingManagerProxy.swift index a974c59201..ac730d27e8 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Tracking/TrackingManagerProxy.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Tracking/TrackingManagerProxy.swift @@ -36,11 +36,14 @@ final class TrackingManagerProxy: NSObject { super.init() } - @objc var trackingStatusSelectorName: String { + // Deliberately not `@objc`: an `@objc` member emits its name into the binary's + // Objective-C method metadata, which is the section this file's mangling exists + // to keep Apple's API names out of. These are read from Swift only. + var trackingStatusSelectorName: String { Self.mangledTrackingStatusSelector.rot13() } - @objc var requestTrackingSelectorName: String { + var requestTrackingSelectorName: String { Self.mangledRequestTrackingSelector.rot13() } diff --git a/Sources/SuperwallKit/Permissions/PermissionHandler.swift b/Sources/SuperwallKit/Permissions/PermissionHandler.swift index a3d8f3e5a0..2380d9f54b 100644 --- a/Sources/SuperwallKit/Permissions/PermissionHandler.swift +++ b/Sources/SuperwallKit/Permissions/PermissionHandler.swift @@ -20,6 +20,12 @@ final class PermissionHandler: PermissionHandling { static let contacts = "NSContactsUsageDescription" static let locationWhenInUse = "NSLocationWhenInUseUsageDescription" static let locationAlways = "NSLocationAlwaysAndWhenInUseUsageDescription" + // Only the tracking key is encoded. App Store Connect scans binaries for this + // one specifically and warns that the app "may request permission to track" + // — which blocked apps that link the SDK but never request tracking from + // declaring no tracking in App Privacy. The other keys above are read the same + // way but trigger no such scan, so they stay legible. Don't mangle them + // without a reason, and don't un-mangle this one. // ROT13("NSUserTrackingUsageDescription") static let mangledTracking = "AFHfreGenpxvatHfntrQrfpevcgvba" static var tracking: String { diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index bbdb3ca4de..11655f969c 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -407,12 +407,10 @@ BCF808C7AC319C2B1F0AD52D /* ConfigResponseLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4827295A4E093CAEE2207DDF /* ConfigResponseLogicTests.swift */; }; BCFF20903199DDDE379D81E0 /* InAppReceiptPayloadContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 862888AB2869D09AA55A017D /* InAppReceiptPayloadContainer.swift */; }; BD152F3BA0BC197A5C6C8CC1 /* InAppReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0695B39826F85AACBA833B77 /* InAppReceipt.swift */; }; - BD6ABB9DB883BC62D2407392 /* FakeLocationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2378D0EF4F79DDF0BC45B389 /* FakeLocationManager.swift */; }; BDBEE781EC4910025379F0B6 /* ASN1Serialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7588892D6DD1C4437CF507DE /* ASN1Serialization.swift */; }; BDECE549960DB9A5662939BE /* Tracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F6AFBC7C60A5074ACE8DF88 /* Tracking.swift */; }; BE5BE4ECDE6505182DD92AA1 /* PaywallViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88EBF6FC3090E004EE1377B4 /* PaywallViewControllerDelegate.swift */; }; BEC00305295F34E7D6B4E82A /* SWDebugManagerLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76CE4D7606C896027C76520E /* SWDebugManagerLogic.swift */; }; - BF47C2693495CB8C9DAD7C8D /* FakeContactsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E3589BB187C8C6AA5C9DE71 /* FakeContactsStore.swift */; }; BF9ADF5761C34F584EC416B1 /* PaywallWebEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 260AE52B33B132D5A5C04911 /* PaywallWebEvent.swift */; }; BFBE808BCA151FAE95AE3276 /* PaywallRequestManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1528915438E6714B1F7F7BD4 /* PaywallRequestManager.swift */; }; BFCA9FE6639175011D0369D9 /* PaywallCacheLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9423D7BA0604D88E30161BFB /* PaywallCacheLogic.swift */; }; @@ -420,7 +418,6 @@ C053DEA1266E78107F828B19 /* StoreKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 910786130E2D7EDE2ED5452D /* StoreKitManager.swift */; }; C09B8BBB7002DA446E9F74E1 /* FactoryProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E30544869C5469AA31832 /* FactoryProtocols.swift */; }; C18384B9272067CFE7C7610D /* StoreProductType.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A959F1F550446C980DC5E5 /* StoreProductType.swift */; }; - C22C4011CB9F4D4FA99F8206 /* FakeAudioSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 673E3BA33A9530AD1168346A /* FakeAudioSession.swift */; }; C23744AAAF31A533693281B6 /* SurveyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B5ABEC694245E0DC00E409 /* SurveyManager.swift */; }; C2A9B3F073EA27F9CD6FCA02 /* AdServicesAttributionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A82783401B92298C47BF14F7 /* AdServicesAttributionTests.swift */; }; C32766E26E92FD03B1BA60A3 /* ProductPurchaserSK1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A56D712042043783D7CA142 /* ProductPurchaserSK1.swift */; }; @@ -670,7 +667,6 @@ 22D96B4C9B546F7B0EC73397 /* PaywallViewControllerWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerWrapper.swift; sourceTree = ""; }; 23307BBFD80385233DDD4C43 /* AssetResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetResource.swift; sourceTree = ""; }; 236900A8A8F95CE92E612458 /* IdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityManager.swift; sourceTree = ""; }; - 2378D0EF4F79DDF0BC45B389 /* FakeLocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeLocationManager.swift; sourceTree = ""; }; 23886A83274F67B1DCB8573A /* SWWebViewLoadingHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWWebViewLoadingHandlerTests.swift; sourceTree = ""; }; 2446E377C8C87A1C087B16B4 /* CELEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CELEvaluator.swift; sourceTree = ""; }; 24B8D7F537204EAB13BB7F10 /* FeatureGatingBehaviour.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureGatingBehaviour.swift; sourceTree = ""; }; @@ -813,7 +809,6 @@ 65EDE0BAE33F351217CC8E2D /* TemplateLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateLogicTests.swift; sourceTree = ""; }; 65F4CF06DE50031C329ED96F /* NotificationSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSchedulerTests.swift; sourceTree = ""; }; 672776875A4286319C2F2D61 /* PaywallViewControllerCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerCacheTests.swift; sourceTree = ""; }; - 673E3BA33A9530AD1168346A /* FakeAudioSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeAudioSession.swift; sourceTree = ""; }; 6752063E4547657E20072CE7 /* ConfirmHoldoutAssignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmHoldoutAssignment.swift; sourceTree = ""; }; 67602AF9B2543CAD0B42F3CF /* CacheMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheMock.swift; sourceTree = ""; }; 67C4FC41FEE0B47EA402D738 /* LocalizationConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationConfig.swift; sourceTree = ""; }; @@ -910,7 +905,6 @@ 8D9545633A97A2E63FEDF78A /* SWWebViewLoadingHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWWebViewLoadingHandler.swift; sourceTree = ""; }; 8DE36D141F461F6E945823FA /* Future+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Future+Async.swift"; sourceTree = ""; }; 8E321E7EEC07CA9A8B9A5619 /* PageViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageViewData.swift; sourceTree = ""; }; - 8E3589BB187C8C6AA5C9DE71 /* FakeContactsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeContactsStore.swift; sourceTree = ""; }; 8E441343EAC43B2ECF35F929 /* SuperwallDelegateAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuperwallDelegateAdapter.swift; sourceTree = ""; }; 8E9E1A8F57B5DCA4CD16296F /* Dictionary+Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+Keys.swift"; sourceTree = ""; }; 8EF6F057A58A42193F279BE6 /* GetPaywallComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetPaywallComponents.swift; sourceTree = ""; }; @@ -1824,7 +1818,6 @@ isa = PBXGroup; children = ( D198C8645A213EEAD622C881 /* FakeLocationAuthorizationStatus.swift */, - 2378D0EF4F79DDF0BC45B389 /* FakeLocationManager.swift */, 80F46655FBF0A6425DA8EF4B /* LocationManagerProxy.swift */, 6B103FA8F9AE387E7DB4B471 /* LocationPermissionDelegate.swift */, C937320625239F3E10FE8D8E /* PermissionsHandler+Location.swift */, @@ -2414,7 +2407,6 @@ children = ( 6F35F68AF572F7CDF174320C /* ContactStoreProxy.swift */, 8708F74D80CB9A440F34A556 /* FakeContactsAuthorizationStatus.swift */, - 8E3589BB187C8C6AA5C9DE71 /* FakeContactsStore.swift */, D561728FDB68F572D5E33223 /* PermissionsHandler+Contacts.swift */, ); path = Contacts; @@ -2522,7 +2514,6 @@ isa = PBXGroup; children = ( B7E0E27369A406D3492A11E2 /* AudioSessionProxy.swift */, - 673E3BA33A9530AD1168346A /* FakeAudioSession.swift */, D7434029CB9E4680C85D3FB6 /* PermissionHandler+Microphone.swift */, ); path = Microphone; @@ -3483,11 +3474,8 @@ 6DA0EC8307544D09C2478EBB /* ExperimentTemplate.swift in Sources */, 44829144E9EFA0CE4A75BBA1 /* ExpressionLogic.swift in Sources */, C09B8BBB7002DA446E9F74E1 /* FactoryProtocols.swift in Sources */, - C22C4011CB9F4D4FA99F8206 /* FakeAudioSession.swift in Sources */, 07CAD1B0A849A7593B1EF6D4 /* FakeContactsAuthorizationStatus.swift in Sources */, - BF47C2693495CB8C9DAD7C8D /* FakeContactsStore.swift in Sources */, 11798EDE58E5D225E5414F2E /* FakeLocationAuthorizationStatus.swift in Sources */, - BD6ABB9DB883BC62D2407392 /* FakeLocationManager.swift in Sources */, E9D95044254D79D2439D7B3E /* FakeTrackingAuthorizationStatus.swift in Sources */, E0F3648081AB86077201EB5D /* FeatureFlags.swift in Sources */, ED1C693657DA7FCBAE2DDDC6 /* FeatureGatingBehaviour.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Permissions/Location/LocationManagerProxyTests.swift b/Tests/SuperwallKitTests/Permissions/Location/LocationManagerProxyTests.swift index b8322522d5..6680349be6 100644 --- a/Tests/SuperwallKitTests/Permissions/Location/LocationManagerProxyTests.swift +++ b/Tests/SuperwallKitTests/Permissions/Location/LocationManagerProxyTests.swift @@ -50,45 +50,36 @@ struct LocationManagerProxyTests { } } -// MARK: - FakeLocationManager Tests +// MARK: - Missing CoreLocation Tests +/// These replace the tests for the deleted `FakeLocationManager`. The fake stood in +/// for `CLLocationManager` when CoreLocation was unavailable, but its `@objc` members +/// emitted Apple's real selector names into the binary — the leak this SDK's mangling +/// exists to prevent. The proxy now guards on a missing class instead, so pin what it +/// returns down that path. @Suite -struct FakeLocationManagerTests { - @Test func authorizationStatus_returnsNotDetermined() { - let manager = FakeLocationManager() - #expect(manager.authorizationStatus == FakeLocationAuthorizationStatus.notDetermined.rawValue) +struct LocationManagerProxyMissingClassTests { + @Test func missingManager_authorizationStatus_returnsNotDetermined() { + let proxy = LocationManagerProxy(locationManagerClass: nil) + #expect(proxy.authorizationStatus() == FakeLocationAuthorizationStatus.notDetermined.rawValue) } - @Test func requestWhenInUseAuthorization_doesNotCrash() { - let manager = FakeLocationManager() - manager.requestWhenInUseAuthorization() - // Should complete without crashing + /// Reports failure rather than the fake's silent success. The caller resumes with + /// `.unsupported` on `false`; with the fake it saw `true` and then waited forever + /// for a delegate callback the fake never made. + @Test func missingManager_requestWhenInUseAuthorization_reportsFailure() { + let proxy = LocationManagerProxy(locationManagerClass: nil) + #expect(proxy.requestWhenInUseAuthorization() == false) } - @Test func requestAlwaysAuthorization_doesNotCrash() { - let manager = FakeLocationManager() - manager.requestAlwaysAuthorization() - // Should complete without crashing + @Test func missingManager_requestAlwaysAuthorization_reportsFailure() { + let proxy = LocationManagerProxy(locationManagerClass: nil) + #expect(proxy.requestAlwaysAuthorization() == false) } - @Test func delegate_canBeSet() { - let manager = FakeLocationManager() - let delegate = NSObject() - - manager.delegate = delegate - #expect(manager.delegate === delegate) - } - - @Test func delegate_isWeak() { - let manager = FakeLocationManager() - - autoreleasepool { - let delegate = NSObject() - manager.delegate = delegate - #expect(manager.delegate != nil) - } - - // After autoreleasepool, the delegate should be deallocated - #expect(manager.delegate == nil) + @Test func missingManager_setDelegate_doesNotCrash() { + let proxy = LocationManagerProxy(locationManagerClass: nil) + proxy.setDelegate(LocationPermissionDelegate { _ in }) + proxy.setDelegate(nil) } } diff --git a/Tests/SuperwallKitTests/Permissions/MicrophonePermissionTests.swift b/Tests/SuperwallKitTests/Permissions/MicrophonePermissionTests.swift index 659f471883..7fe1be96a1 100644 --- a/Tests/SuperwallKitTests/Permissions/MicrophonePermissionTests.swift +++ b/Tests/SuperwallKitTests/Permissions/MicrophonePermissionTests.swift @@ -78,32 +78,52 @@ struct AudioSessionProxyTests { @Test func sharedInstance_returnsNonNil() { let proxy = AudioSessionProxy() - // In test environment, this should return either real AVAudioSession or FakeAudioSession + // AVAudioSession resolves at runtime on every platform the tests run on. let instance = proxy.sharedInstance() #expect(instance != nil) } } +/// These replace the tests for the deleted `FakeAudioSession`. The fake stood in for +/// `AVAudioSession` when AVFoundation was unavailable, but its `@objc` members emitted +/// Apple's real selector names into the binary — the leak this SDK's mangling exists to +/// prevent. The proxy now guards on a missing class instead, so pin what it returns +/// down that path. @Suite -struct FakeAudioSessionTests { - @Test func sharedInstance_returnsFakeAudioSession() { - let instance = FakeAudioSession.sharedInstance() - #expect(instance is FakeAudioSession) +struct AudioSessionProxyMissingClassTests { + @Test func missingSession_sharedInstance_returnsNil() { + let proxy = AudioSessionProxy(audioSessionClass: nil) + #expect(proxy.sharedInstance() == nil) } - @Test func recordPermission_returnsNegativeOne() { - let fake = FakeAudioSession() - #expect(fake.recordPermission() == -1) + /// -1 is the "unavailable" sentinel `checkMicrophonePermission()` maps to + /// `.unsupported`, and is what the fake's `recordPermission()` returned. + @Test func missingSession_recordPermission_returnsUnavailable() { + let proxy = AudioSessionProxy(audioSessionClass: nil) + #expect(proxy.recordPermission() == -1) } - @Test func requestRecordPermission_callsCompletionWithFalse() { - let fake = FakeAudioSession() - var result: Bool? + @Test func missingSession_requestRecordPermission_returnsFalse() async { + let proxy = AudioSessionProxy(audioSessionClass: nil) + let granted = await proxy.requestRecordPermission() + #expect(granted == false) + } +} - fake.requestRecordPermission { granted in - result = granted - } +/// `ContactStoreProxy` had no fake-class tests, but it carried the same `@objc` fake. +/// Pin its guarded path too, so the deleted `FakeContactStore` can't quietly return. +@Suite +struct ContactStoreProxyMissingClassTests { + /// -1 is the "unavailable" sentinel `checkContactsPermission()` maps to + /// `.unsupported`, and is what the fake's class method returned. + @Test func missingStore_authorizationStatus_returnsUnavailable() { + let proxy = ContactStoreProxy(contactStoreClass: nil) + #expect(proxy.authorizationStatus() == -1) + } - #expect(result == false) + @Test func missingStore_requestAccess_returnsFalse() async throws { + let proxy = ContactStoreProxy(contactStoreClass: nil) + let granted = try await proxy.requestAccess() + #expect(granted == false) } } diff --git a/scripts/scan-privacy-signatures.sh b/scripts/scan-privacy-signatures.sh new file mode 100755 index 0000000000..d9e0647f24 --- /dev/null +++ b/scripts/scan-privacy-signatures.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# Copyright (c) Nest22. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. +# +# Fails when a permission API name Apple's App Store Connect scanner reacts to is +# compiled into the framework binary. +# +# The SDK reaches these APIs through the Objective-C runtime with ROT13-encoded class +# and selector names, so a correct build contains none of them in plaintext. They come +# back in two ways, both of which this catches: a string literal (an un-mangled plist +# key) lands in __TEXT,__cstring, and an `@objc` member (a fake stand-in class) lands +# in __TEXT,__objc_methname. Unit tests can't see either — only the built binary can. +# +# Scans those two sections rather than running `strings` over the whole binary: Swift +# mangles internal symbol names using the source names, so a debug binary legitimately +# contains "trackingAuthorizationStatus" inside `TrackingManagerProxy`'s own symbols. +# Matching on that would fail forever with nothing to fix. + +set -e + +cd "$(dirname "$0")/.." + +BINARY="$1" + +if [ -z "$BINARY" ]; then + echo "usage: $0 " + exit 2 +fi + +if [ ! -f "$BINARY" ]; then + echo "❌ Not a file: $BINARY" + exit 2 +fi + +# Add a name here when the SDK starts reaching a new permission API by runtime lookup. +FORBIDDEN=( + # Tracking — the one App Store Connect actively warns about. + "NSUserTrackingUsageDescription" + "ATTrackingManager" + "requestTrackingAuthorizationWithCompletionHandler:" + # Microphone, location, and contacts — reached the same way, so hold them to the + # same standard even though no scanner is known to flag them. + "AVAudioSession" + "requestRecordPermission:" + "CLLocationManager" + "requestWhenInUseAuthorization" + "requestAlwaysAuthorization" + "CNContactStore" + "requestAccessForEntityType:completionHandler:" +) + +echo "🔍 Scanning $(basename "$BINARY") for privacy API signatures..." + +SECTIONS=$( + { + otool -v -s __TEXT __objc_methname "$BINARY" 2>/dev/null + otool -v -s __TEXT __objc_classname "$BINARY" 2>/dev/null + otool -v -s __TEXT __cstring "$BINARY" 2>/dev/null + } +) + +if [ -z "$SECTIONS" ]; then + echo "❌ Could not read Objective-C metadata from $BINARY." + exit 2 +fi + +FOUND=() +for name in "${FORBIDDEN[@]}"; do + if grep -qF "$name" <<< "$SECTIONS"; then + FOUND+=("$name") + fi +done + +if [ ${#FOUND[@]} -ne 0 ]; then + echo "❌ Found permission API signatures in the compiled binary:" + for name in "${FOUND[@]}"; do + echo " - $name" + done + echo "" + echo " These reach the binary from a plaintext string literal or an @objc member." + echo " Store the name ROT13-encoded and decode it at runtime, and don't declare" + echo " @objc stand-in classes that mirror Apple's selectors — guard on the missing" + echo " class instead. See Sources/SuperwallKit/Permissions/Handlers/." + exit 1 +fi + +echo "✅ No privacy API signatures found." From 110667d001fd97b777159c763e9521e90b6cfcc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:17:23 +0200 Subject: [PATCH 3/4] fix(permissions): close the #function leak the scanner was blind to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renaming a proxy method away from Apple's name only works if every method gets the treatment: `withCheckedContinuation`'s `function: String = #function` default expands the enclosing method name into a string literal, which put `requestRecordPermission()` — and, more weakly, `requestAccess()` — into __cstring after the fakes were already gone. The scanner missed the first because its entry carried the ObjC trailing colon and `grep -F` matches exact substrings. Rename both to `requestPermission()`, and switch the FORBIDDEN list to bare names so one entry catches a selector, a #function expansion, or any future suffix. Validating the bare entries against the binary surfaced a scope boundary worth recording: the camera handler calls `AVCaptureDevice.requestAccess(for:)` directly, so `requestAccessForMediaType:completionHandler:` is legitimately present — camera, photos, and notifications never joined the proxy scheme. The list notes that, keeps the contacts entry to `requestAccessForEntityType`, and documents the other deliberate omission: `LocationPermissionDelegate`'s two callback selectors, which CLLocationManager dispatches by name at runtime and which therefore cannot lose their metadata. Its KVC key now decodes from the existing mangled constant instead of sitting in __cstring as plaintext. Also from the review: run the tests workflow when only `scripts/**` changes, so edits to the scanner are exercised by the scanner; inline the location proxy's class lookup into its init default to match the other three; move the contacts proxy tests into their own file beside Location/ and Tracking/; and reword the changelog line that read as dead-code elimination. Scan verified both ways again: green here, and 8 names flagged on the last develop build now that bare entries also catch the #function forms. Co-Authored-By: Claude Fable 5 --- .github/workflows/tests.yml | 1 + CHANGELOG.md | 2 +- .../Handlers/Contacts/ContactStoreProxy.swift | 5 ++- .../PermissionsHandler+Contacts.swift | 2 +- .../Location/LocationManagerProxy.swift | 10 ++--- .../Location/LocationPermissionDelegate.swift | 15 ++++++- .../Microphone/AudioSessionProxy.swift | 6 ++- .../PermissionHandler+Microphone.swift | 2 +- SuperwallKit.xcodeproj/project.pbxproj | 12 ++++++ .../Contacts/ContactStoreProxyTests.swift | 43 +++++++++++++++++++ .../MicrophonePermissionTests.swift | 22 +--------- scripts/scan-privacy-signatures.sh | 25 +++++++++-- 12 files changed, 110 insertions(+), 35 deletions(-) create mode 100644 Tests/SuperwallKitTests/Permissions/Contacts/ContactStoreProxyTests.swift diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c19b002c89..d61761b6d1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,6 +5,7 @@ on: paths: - '.github/workflows/tests.yml' - 'project.yml' + - 'scripts/**' - '**/*.swift' - '!Examples/**' workflow_dispatch: diff --git a/CHANGELOG.md b/CHANGELOG.md index 4102c03e71..02367a0bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes issue where the paywall debugger wouldn't work for accounts with many paywalls. - Fixes Main Thread Checker warnings caused by reading the device's interface style and text size from a background thread. - Prevents unused App Tracking Transparency support from triggering App Store Connect tracking warnings. -- Keeps unused microphone, location, and contacts permission code out of your app's binary too. +- Stops Apple's microphone, location, and contacts permission API names appearing in your app's binary when you don't use those permissions. ## 4.16.1 diff --git a/Sources/SuperwallKit/Permissions/Handlers/Contacts/ContactStoreProxy.swift b/Sources/SuperwallKit/Permissions/Handlers/Contacts/ContactStoreProxy.swift index a53da1902c..ba69dff0e4 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Contacts/ContactStoreProxy.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Contacts/ContactStoreProxy.swift @@ -78,7 +78,10 @@ final class ContactStoreProxy: NSObject { return function(cls as AnyObject, sel, Self.contactsEntityType) } - func requestAccess() async throws -> Bool { + // Named away from Apple's `requestAccess` deliberately: + // `withCheckedThrowingContinuation`'s `function: String = #function` default + // expands the enclosing method name into a string literal in the binary. + func requestPermission() async throws -> Bool { guard let storeType = contactStoreClass as? NSObject.Type else { return false } diff --git a/Sources/SuperwallKit/Permissions/Handlers/Contacts/PermissionsHandler+Contacts.swift b/Sources/SuperwallKit/Permissions/Handlers/Contacts/PermissionsHandler+Contacts.swift index b9953f0b59..983520685b 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Contacts/PermissionsHandler+Contacts.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Contacts/PermissionsHandler+Contacts.swift @@ -28,7 +28,7 @@ extension PermissionHandler { do { let proxy = ContactStoreProxy() - let granted = try await proxy.requestAccess() + let granted = try await proxy.requestPermission() return granted ? .granted : .denied } catch { Logger.debug( diff --git a/Sources/SuperwallKit/Permissions/Handlers/Location/LocationManagerProxy.swift b/Sources/SuperwallKit/Permissions/Handlers/Location/LocationManagerProxy.swift index 29ecc1ba2a..af44a6fe0e 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Location/LocationManagerProxy.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Location/LocationManagerProxy.swift @@ -30,13 +30,13 @@ final class LocationManagerProxy: NSObject { // ROT13("setDelegate:") static let mangledSetDelegateSelector = "frgQryrtngr:" - static var locationManagerClass: AnyClass? { - NSClassFromString(mangledLocationManagerClassName.rot13()) - } - private var locationManager: NSObject? - init(locationManagerClass: AnyClass? = LocationManagerProxy.locationManagerClass) { + init( + locationManagerClass: AnyClass? = NSClassFromString( + LocationManagerProxy.mangledLocationManagerClassName.rot13() + ) + ) { super.init() guard let managerType = locationManagerClass as? NSObject.Type else { return diff --git a/Sources/SuperwallKit/Permissions/Handlers/Location/LocationPermissionDelegate.swift b/Sources/SuperwallKit/Permissions/Handlers/Location/LocationPermissionDelegate.swift index 38968fdaad..4867efc98b 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Location/LocationPermissionDelegate.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Location/LocationPermissionDelegate.swift @@ -9,6 +9,15 @@ import Foundation /// Delegate class to handle location authorization callbacks. /// Implements both iOS 14+ and iOS 13 delegate methods dynamically. +/// +/// The two `@objc` methods below put CoreLocation's exact delegate selectors into +/// the binary's Objective-C metadata — the section the proxies' mangling otherwise +/// keeps Apple's names out of. That's accepted, not overlooked: `CLLocationManager` +/// dispatches its delegate callbacks by these selectors at runtime, so the metadata +/// must carry them for the callbacks to arrive. Removing them would mean assembling +/// this class at runtime with `objc_allocateClassPair`. They're also callback names, +/// not request-API names or usage-description keys — nothing scanners are known to +/// react to. `scan-privacy-signatures.sh` deliberately leaves them off its list. final class LocationPermissionDelegate: NSObject { private let onStatusChange: (Int) -> Void private var hasCompleted = false @@ -37,8 +46,10 @@ final class LocationPermissionDelegate: NSObject { #endif private func currentAuthorizationStatus(from manager: AnyObject) -> Int { - // Try instance property first (iOS 14+) - if let status = manager.value(forKey: "authorizationStatus") as? Int { + // Try instance property first (iOS 14+). The key is decoded at runtime so the + // name doesn't sit in the binary as a plaintext literal. + let key = LocationManagerProxy.mangledAuthorizationStatusSelector.rot13() + if let status = manager.value(forKey: key) as? Int { return status } return FakeLocationAuthorizationStatus.notDetermined.rawValue diff --git a/Sources/SuperwallKit/Permissions/Handlers/Microphone/AudioSessionProxy.swift b/Sources/SuperwallKit/Permissions/Handlers/Microphone/AudioSessionProxy.swift index bc01ced3d9..6921ff9f7d 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Microphone/AudioSessionProxy.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Microphone/AudioSessionProxy.swift @@ -81,7 +81,11 @@ final class AudioSessionProxy: NSObject { return function(instance, sel) } - func requestRecordPermission() async -> Bool { + // Named away from Apple's `requestRecordPermission` deliberately: + // `withCheckedContinuation`'s `function: String = #function` default expands the + // enclosing method name into a string literal in the binary, which is the same + // leak the mangling exists to prevent. + func requestPermission() async -> Bool { guard let cls = audioSessionClass, let instance = sharedInstance() diff --git a/Sources/SuperwallKit/Permissions/Handlers/Microphone/PermissionHandler+Microphone.swift b/Sources/SuperwallKit/Permissions/Handlers/Microphone/PermissionHandler+Microphone.swift index 625b7ffe25..4e9515744f 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Microphone/PermissionHandler+Microphone.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Microphone/PermissionHandler+Microphone.swift @@ -30,7 +30,7 @@ extension PermissionHandler { } let proxy = AudioSessionProxy() - let granted = await proxy.requestRecordPermission() + let granted = await proxy.requestPermission() return granted ? .granted : .denied } } diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 11655f969c..fedc0f9731 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -441,6 +441,7 @@ C9F6A490E3E09401494E7DCB /* CheckDebuggerPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9777790D2B73EFF94E7C648 /* CheckDebuggerPresentation.swift */; }; CB1E11FB74879A29DD1C9EB1 /* Encodable+Dictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E6016BF483A4C47FF7A7C0 /* Encodable+Dictionary.swift */; }; CB2F2B4DA3709F171E54CBB8 /* DeepLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EA0BD57CE7F03A50ACA9D25 /* DeepLinkRouter.swift */; }; + CBC90EC17DC1EC4C2FE9DFC8 /* ContactStoreProxyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC2A1B3F139D6D01D2E8A0F /* ContactStoreProxyTests.swift */; }; CBFC0D2DCA996A5FF7E5174B /* CacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4DC3F3B888F2DC4CC4747CB /* CacheTests.swift */; }; CD324457E6206D0E303A6B15 /* ArchiveURLFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81C5A241FC9EF921D4E08FF1 /* ArchiveURLFetcher.swift */; }; CD7A815C87F9AFF406BFE9A0 /* AuthorizationStatus+PermissionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E97A346D7F7C59DF55F62B /* AuthorizationStatus+PermissionStatus.swift */; }; @@ -725,6 +726,7 @@ 3A7BF742EBC950966662849F /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; 3B8C73737C103C168647DCFB /* ExpressionLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpressionLogicTests.swift; sourceTree = ""; }; 3C1EB433A4E4342E03BB7744 /* IdentityInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityInfo.swift; sourceTree = ""; }; + 3CC2A1B3F139D6D01D2E8A0F /* ContactStoreProxyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactStoreProxyTests.swift; sourceTree = ""; }; 3CDFBF0FA8B313E0D84A51DB /* SWConsoleViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWConsoleViewController.swift; sourceTree = ""; }; 3D8AD1A7B62E8CBBDFB65BE5 /* TrackableSuperwallEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackableSuperwallEvent.swift; sourceTree = ""; }; 3E3E1BAFC4A22DC46C49F00C /* String+RemoveChars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+RemoveChars.swift"; sourceTree = ""; }; @@ -1439,6 +1441,14 @@ path = Purchasing; sourceTree = ""; }; + 1D9B1E5662EEA7F02C67C4A6 /* Contacts */ = { + isa = PBXGroup; + children = ( + 3CC2A1B3F139D6D01D2E8A0F /* ContactStoreProxyTests.swift */, + ); + path = Contacts; + sourceTree = ""; + }; 1DE8CF6D5ACEFA6B6349B142 /* Loading */ = { isa = PBXGroup; children = ( @@ -1471,6 +1481,7 @@ ABD045A5C4A47B1CA9365285 /* MicrophonePermissionTests.swift */, 988E0E3F8D992744C9AC196F /* PermissionStatusTests.swift */, DFD2580D6C95C96CC3051BCB /* PermissionTypeTests.swift */, + 1D9B1E5662EEA7F02C67C4A6 /* Contacts */, 8E656889D53C36053C472FF2 /* Location */, 267D9A6611768B7308E16E21 /* Tracking */, ); @@ -3246,6 +3257,7 @@ E63BD2E9AA7F1CCC70C889D5 /* ConfigResponseTests.swift in Sources */, AE1D15070BC159212967CAD4 /* ConfirmHoldoutAssignmentTests.swift in Sources */, 61EC2A032D127B323D4A8124 /* ConfirmPaywallAssignmentOperatorTests.swift in Sources */, + CBC90EC17DC1EC4C2FE9DFC8 /* ContactStoreProxyTests.swift in Sources */, AEAB0C0C168B0B3D864109EA /* CoreDataManagerFakeDataMock.swift in Sources */, A1621A749D8F05959A486ACE /* CoreDataManagerMock.swift in Sources */, C7AB21123540550E513AD28A /* CoreDataManagerTests.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Permissions/Contacts/ContactStoreProxyTests.swift b/Tests/SuperwallKitTests/Permissions/Contacts/ContactStoreProxyTests.swift new file mode 100644 index 0000000000..404ef1426b --- /dev/null +++ b/Tests/SuperwallKitTests/Permissions/Contacts/ContactStoreProxyTests.swift @@ -0,0 +1,43 @@ +// +// ContactStoreProxyTests.swift +// SuperwallKitTests +// +// Created by Yusuf Tör on 11/08/2026. +// + +import Foundation +import Testing +@testable import SuperwallKit + +@Suite +struct ContactStoreProxyTests { + @Test func mangledClassName_decodesCorrectly() { + let className = ContactStoreProxy.mangledContactStoreClassName.rot13() + #expect(className == "CNContactStore") + } + + @Test func selectorNames_areCorrectlyDecoded() { + let proxy = ContactStoreProxy() + + #expect(proxy.authorizationStatusSelectorName == "authorizationStatusForEntityType:") + #expect(proxy.requestAccessSelectorName == "requestAccessForEntityType:completionHandler:") + } +} + +/// `ContactStoreProxy` carried the same `@objc` fake as the other handlers. Pin its +/// guarded path, so the deleted `FakeContactStore` can't quietly return. +@Suite +struct ContactStoreProxyMissingClassTests { + /// -1 is the "unavailable" sentinel `checkContactsPermission()` maps to + /// `.unsupported`, and is what the fake's class method returned. + @Test func missingStore_authorizationStatus_returnsUnavailable() { + let proxy = ContactStoreProxy(contactStoreClass: nil) + #expect(proxy.authorizationStatus() == -1) + } + + @Test func missingStore_requestPermission_returnsFalse() async throws { + let proxy = ContactStoreProxy(contactStoreClass: nil) + let granted = try await proxy.requestPermission() + #expect(granted == false) + } +} diff --git a/Tests/SuperwallKitTests/Permissions/MicrophonePermissionTests.swift b/Tests/SuperwallKitTests/Permissions/MicrophonePermissionTests.swift index 7fe1be96a1..a7c64b2ece 100644 --- a/Tests/SuperwallKitTests/Permissions/MicrophonePermissionTests.swift +++ b/Tests/SuperwallKitTests/Permissions/MicrophonePermissionTests.swift @@ -103,27 +103,9 @@ struct AudioSessionProxyMissingClassTests { #expect(proxy.recordPermission() == -1) } - @Test func missingSession_requestRecordPermission_returnsFalse() async { + @Test func missingSession_requestPermission_returnsFalse() async { let proxy = AudioSessionProxy(audioSessionClass: nil) - let granted = await proxy.requestRecordPermission() - #expect(granted == false) - } -} - -/// `ContactStoreProxy` had no fake-class tests, but it carried the same `@objc` fake. -/// Pin its guarded path too, so the deleted `FakeContactStore` can't quietly return. -@Suite -struct ContactStoreProxyMissingClassTests { - /// -1 is the "unavailable" sentinel `checkContactsPermission()` maps to - /// `.unsupported`, and is what the fake's class method returned. - @Test func missingStore_authorizationStatus_returnsUnavailable() { - let proxy = ContactStoreProxy(contactStoreClass: nil) - #expect(proxy.authorizationStatus() == -1) - } - - @Test func missingStore_requestAccess_returnsFalse() async throws { - let proxy = ContactStoreProxy(contactStoreClass: nil) - let granted = try await proxy.requestAccess() + let granted = await proxy.requestPermission() #expect(granted == false) } } diff --git a/scripts/scan-privacy-signatures.sh b/scripts/scan-privacy-signatures.sh index d9e0647f24..77d254fe13 100755 --- a/scripts/scan-privacy-signatures.sh +++ b/scripts/scan-privacy-signatures.sh @@ -35,20 +35,39 @@ if [ ! -f "$BINARY" ]; then fi # Add a name here when the SDK starts reaching a new permission API by runtime lookup. +# +# Entries are bare names, not full selectors: a leaked name can wear several suffixes +# — "requestRecordPermission:" from an `@objc` member, "requestRecordPermission()" +# from `withCheckedContinuation`'s `#function` default — and the bare form matches +# them all. An entry must never legitimately appear in the scanned sections, so a hit +# is always a real leak. +# +# Deliberately absent: `LocationPermissionDelegate`'s callback selectors +# (locationManagerDidChangeAuthorization:, locationManager:didChangeAuthorization:). +# CLLocationManager dispatches those by selector at runtime, so their metadata has to +# exist for the callbacks to arrive. See the note on that class. +# +# Also absent: names from the camera, photos, and notification handlers. Those call +# their frameworks directly rather than through mangled runtime lookups — e.g. +# `AVCaptureDevice.requestAccess(for:)` legitimately emits +# `requestAccessForMediaType:completionHandler:` — so their names in the binary are +# how the SDK works today, not a leak. If they ever join the proxy scheme, add them. FORBIDDEN=( # Tracking — the one App Store Connect actively warns about. "NSUserTrackingUsageDescription" "ATTrackingManager" - "requestTrackingAuthorizationWithCompletionHandler:" + "requestTrackingAuthorization" + "trackingAuthorizationStatus" # Microphone, location, and contacts — reached the same way, so hold them to the # same standard even though no scanner is known to flag them. "AVAudioSession" - "requestRecordPermission:" + "recordPermission" + "requestRecordPermission" "CLLocationManager" "requestWhenInUseAuthorization" "requestAlwaysAuthorization" "CNContactStore" - "requestAccessForEntityType:completionHandler:" + "requestAccessForEntityType" ) echo "🔍 Scanning $(basename "$BINARY") for privacy API signatures..." From 64261cca075eba0b3665e9ce5351dae603f303c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:40:02 +0200 Subject: [PATCH 4/4] chore(permissions): guard the AdSupport names and sharpen the changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups, both non-blocking. The scanner enumerated its deliberate omissions (the location delegate's runtime-dispatched callbacks; the camera/photos/notification handlers that call Apple directly) but said nothing about AdSupport, so a green scan read as a completeness claim it didn't make. `FakeASIdManager` is a real @objc shim, but unlike the four deleted permission fakes it's load-bearing — it's what makes `classType.sharedManager()` typecheck — and `sharedManager` is a generic selector that fingerprints nothing. The names that would fingerprint, `ASIdentifierManager` and `advertisingIdentifier`, are already mangled by ASIdManagerProxy, so add them to the forbidden list (both verified absent from the current binary) and record why the shim itself stays. Changelog said "permission API names", broader than what shipped — the usage-description keys are still plaintext by design. Say "class and selector names", which is what actually stopped appearing. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- scripts/scan-privacy-signatures.sh | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02367a0bd2..c11469892d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes issue where the paywall debugger wouldn't work for accounts with many paywalls. - Fixes Main Thread Checker warnings caused by reading the device's interface style and text size from a background thread. - Prevents unused App Tracking Transparency support from triggering App Store Connect tracking warnings. -- Stops Apple's microphone, location, and contacts permission API names appearing in your app's binary when you don't use those permissions. +- Stops Apple's microphone, location, and contacts class and selector names appearing in your app's binary when you don't use those permissions. ## 4.16.1 diff --git a/scripts/scan-privacy-signatures.sh b/scripts/scan-privacy-signatures.sh index 77d254fe13..e15154a74e 100755 --- a/scripts/scan-privacy-signatures.sh +++ b/scripts/scan-privacy-signatures.sh @@ -52,6 +52,14 @@ fi # `AVCaptureDevice.requestAccess(for:)` legitimately emits # `requestAccessForMediaType:completionHandler:` — so their names in the binary are # how the SDK works today, not a leak. If they ever join the proxy scheme, add them. +# +# Also absent, and unlike the above this one is a genuine `@objc` shim: `FakeASIdManager` +# (its class name, and its `sharedManager` selector). It survives where the four +# permission fakes were deleted because it is load-bearing — it's what makes +# `classType.sharedManager()` typecheck through AnyObject lookup — and `sharedManager` +# is a generic Cocoa selector shared by many classes, so it fingerprints nothing. The +# AdSupport names that would fingerprint (the class and property below) are mangled, and +# guarded here. FORBIDDEN=( # Tracking — the one App Store Connect actively warns about. "NSUserTrackingUsageDescription" @@ -68,6 +76,10 @@ FORBIDDEN=( "requestAlwaysAuthorization" "CNContactStore" "requestAccessForEntityType" + # AdSupport (IDFA) — reached through the same runtime-lookup proxy, which mangles + # both the class name and the `advertisingIdentifier` property. + "ASIdentifierManager" + "advertisingIdentifier" ) echo "🔍 Scanning $(basename "$BINARY") for privacy API signatures..."