Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
paths:
- '.github/workflows/tests.yml'
- 'project.yml'
- 'scripts/**'
- '**/*.swift'
- '!Examples/**'
workflow_dispatch:
Expand Down Expand Up @@ -33,4 +34,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
Comment thread
yusuftor marked this conversation as resolved.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ 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.
- 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand All @@ -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 {
Expand All @@ -66,10 +78,11 @@ final class ContactStoreProxy: NSObject {
return function(cls as AnyObject, sel, Self.contactsEntityType)
}

func requestAccess() async throws -> Bool {
let cls: AnyClass = Self.contactStoreClass ?? FakeContactStore.self

guard let storeType = cls as? NSObject.Type else {
// 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
}

Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,36 @@ final class LocationManagerProxy: NSObject {
// ROT13("setDelegate:")
static let mangledSetDelegateSelector = "frgQryrtngr:"

static var locationManagerClass: AnyClass? {
NSClassFromString(mangledLocationManagerClassName.rot13())
private var locationManager: NSObject?

init(
locationManagerClass: AnyClass? = NSClassFromString(
LocationManagerProxy.mangledLocationManagerClassName.rot13()
)
) {
super.init()
guard let managerType = locationManagerClass as? NSObject.Type else {
return
}
locationManager = managerType.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 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()
}

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand All @@ -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 }
Expand All @@ -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
}

Expand All @@ -73,10 +81,15 @@ final class AudioSessionProxy: NSObject {
return function(instance, sel)
}

func requestRecordPermission() async -> Bool {
let cls: AnyClass = Self.audioSessionClass ?? FakeAudioSession.self

guard let instance = sharedInstance() else {
// 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
Comment thread
yusuftor marked this conversation as resolved.
let cls = audioSessionClass,
let instance = sharedInstance()
else {
return false
}

Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ extension PermissionHandler {
}

let proxy = AudioSessionProxy()
let granted = await proxy.requestRecordPermission()
let granted = await proxy.requestPermission()
return granted ? .granted : .denied
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ extension PermissionHandler {
return currentStatus
}

let status = await proxy.requestTrackingAuthorization()
let status = await proxy.requestAuthorization()
let permissionStatus = status.toTrackingPermissionStatus

return permissionStatus
Expand Down
Loading