Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup
- Fixes network requests that can never succeed, such as those with an invalid API key, taking up to a minute to fail instead of failing straight away. Timeouts and server errors still retry as before.
- 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.

## 4.16.1

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,25 @@ extension UIApplication {
let windows = sharedApplication.connectedScenes.flatMap { ($0 as? UIWindowScene)?.windows ?? [] }
return windows.first { $0.isKeyWindow } ?? windows.first
}

/// The scene behind ``activeWindow``, using the same activation-state priority.
///
/// Preferred over the window for trait observation: a window's
/// `overrideUserInterfaceStyle` doesn't propagate up to its scene, so the scene's
/// `userInterfaceStyle` follows the system the way `UIScreen.main` does. It also
/// outlives the individual windows it hosts.
var activeWindowScene: UIWindowScene? {
guard let sharedApplication = UIApplication.sharedApplication else {
return nil
}
if let windowScene = sharedApplication.connectedScenes
.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
return windowScene
}
if let windowScene = sharedApplication.connectedScenes
.first(where: { $0.activationState == .foregroundInactive }) as? UIWindowScene {
return windowScene
}
return sharedApplication.connectedScenes.compactMap { $0 as? UIWindowScene }.first
Comment thread
yusuftor marked this conversation as resolved.
}
}
250 changes: 216 additions & 34 deletions Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,54 +186,218 @@ class DeviceHelper {

var interfaceStyleOverride: InterfaceStyle?

/// Backs `X-Device-Interface-Style`. `getTemplateDevice()` resolves the override
/// the same way against its own trait snapshot — change one and change the other,
/// or the template and the header disagree on a backend-contract field.
///
/// Not folded into a shared `interfaceStyle(from:)` helper on purpose: passing
/// `currentUITraits` would evaluate it eagerly, and below iOS 17 that read is a
/// blocking main-queue hop — one per network request whenever an override is set.
/// The early return here avoids it.
var interfaceStyle: String {
if let interfaceStyleOverride = interfaceStyleOverride {
return interfaceStyleOverride.description
}
#if os(visionOS)
return "Unknown"
#else
let style = UIScreen.main.traitCollection.userInterfaceStyle
switch style {
case .unspecified:
return "Unspecified"
case .light:
return "Light"
case .dark:
return "Dark"
default:
return "Unknown"
return currentUITraits.interfaceStyle
}

var fontSize: Int {
return currentUITraits.fontSize
}

var fontScale: Double {
return currentUITraits.fontScale
}

var preferredContentSizeCategory: String {
return currentUITraits.preferredContentSizeCategory
}

/// Trait-derived values, cached from the main thread.
///
/// `UIApplication.preferredContentSizeCategory`, `UIScreen.traitCollection` and
/// `UIFontMetrics` are main-thread-only, but these are read from background
/// contexts such as `getTemplateDevice()`. They're read on the main thread and
/// cached, then refreshed on trait-change notifications and on read.
@DispatchQueueBacked
private var uiTraits: UITraits

/// Set while a refresh of ``uiTraits`` is already scheduled, so a burst of reads
/// queues one main-thread hop rather than one per read.
@DispatchQueueBacked
private var isRefreshingUITraits = false

private var traitObservers: [NSObjectProtocol] = []

/// The `UITraitChangeRegistration` from ``registerForTraitChanges()``. Typed `Any?`
/// because the protocol is iOS 17+ and a stored property can't be gated on
/// availability.
private var traitChangeRegistration: Any?

/// The scene the registration was made against, held weakly so its deallocation is
/// detectable. UIKit tears a registration down with the object that created it, so
/// a non-nil token whose scene has gone is stale, not live.
private weak var observedTraitScene: UIWindowScene?

/// The current traits.
///
/// Before iOS 17 there is no trait hook, so nothing invalidates the cache when the
/// appearance flips while the app stays active. These values were read live on
/// every access before caching was introduced, and serving a stale one would
/// regress `X-Device-Interface-Style` and the audience filters keyed on it — so
/// those versions read live. `makeUITraits()` makes the main-thread hop itself, and
/// off the main thread that hop *blocks* the caller until the main queue drains.
/// Read this once and reuse the snapshot rather than touching it per field.
///
/// From iOS 17 the hook keeps the cache current, and the scheduled refresh covers
/// the gap between launch and registration.
private var currentUITraits: UITraits {
guard #available(iOS 17.0, *) else {
return DeviceHelper.makeUITraits()
}
refreshUITraits()
return uiTraits
}
Comment thread
pullfrog[bot] marked this conversation as resolved.

private func refreshUITraits() {
#if !os(visionOS)
// The wrapper's setter and getter share one serial queue, so this write is
// ordered ahead of `currentUITraits`' read and the caller sees it.
if Thread.isMainThread {
uiTraits = DeviceHelper.makeUITraits()
return
}
guard !$isRefreshingUITraits.testAndSetTrue() else {
return
}
DispatchQueue.main.async { [weak self] in
self?.uiTraits = DeviceHelper.makeUITraits()
self?.isRefreshingUITraits = false
}
#endif
}
}
Comment thread
pullfrog[bot] marked this conversation as resolved.

var fontSize: Int {
#if os(visionOS)
return 16
#else
return Int(UIFontMetrics.default.scaledValue(for: 16.0).rounded())
/// Updates the cache at the moment the appearance or text size flips.
///
/// There is no notification for `userInterfaceStyle`, so a system appearance
/// change while the app stays active — automatic light/dark at sunset — reaches
/// us only through a trait hook. Without this the cache reports the previous
/// token to `X-Device-Interface-Style` and to audience filters until something
/// else refreshes it.
///
/// Registration needs a scene, which may not exist yet when `DeviceHelper` is
/// created, so this also runs on activation and re-arms if the observed scene has
/// since gone away.
///
/// iOS 17+ only. Earlier releases read live on every access instead, rather than
/// injecting a view into the host's window to reach `traitCollectionDidChange`.
private func registerForTraitChanges() {
#if !os(visionOS)
Task { @MainActor [weak self] in
if #available(iOS 17.0, *) {
self?.performTraitChangeRegistration()
}
}
#endif
}

var fontScale: Double {
#if !os(visionOS)
@available(iOS 17.0, *)
@MainActor
private func performTraitChangeRegistration() {
// Already armed against a scene that still exists.
if traitChangeRegistration != nil && observedTraitScene != nil {
return
}
Comment on lines +309 to +311

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Registration stays on old scene

When a multi-window host keeps the original UIWindowScene alive while another scene becomes foreground-active, this guard treats the old registration as current. Appearance changes in the new active scene then leave uiTraits stale, causing device attributes and request headers to report the previous Light or Dark token and select the wrong audience behavior.

Knowledge Base Used: Networking Layer

Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift
Line: 309-311

Comment:
**Registration stays on old scene**

When a multi-window host keeps the original `UIWindowScene` alive while another scene becomes foreground-active, this guard treats the old registration as current. Appearance changes in the new active scene then leave `uiTraits` stale, causing device attributes and request headers to report the previous Light or Dark token and select the wrong audience behavior.

**Knowledge Base Used:** [Networking Layer](https://app.greptile.com/superwall/-/custom-context/knowledge-base/superwall/superwall-ios/-/docs/networking-layer.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

guard let scene = UIApplication.sharedApplication?.activeWindowScene else {
return
}
observedTraitScene = scene
traitChangeRegistration = scene.registerForTraitChanges(
[UITraitUserInterfaceStyle.self, UITraitPreferredContentSizeCategory.self]
) { [weak self] (_: UITraitEnvironment, _: UITraitCollection) in
self?.uiTraits = DeviceHelper.makeUITraits()
}
}
#endif

private struct UITraits {
let interfaceStyle: String
let fontSize: Int
let fontScale: Double
let preferredContentSizeCategory: String

/// The values used when UIKit traits aren't available, e.g. on visionOS.
static let unavailable = UITraits(
interfaceStyle: "Unknown",
fontSize: 16,
fontScale: 1.0,
preferredContentSizeCategory: "unspecified"
)
}

private static func makeUITraits() -> UITraits {
#if os(visionOS)
return 1.0
return .unavailable
#else
let scale = UIFontMetrics.default.scaledValue(for: 16.0) / 16.0
return (scale * 100).rounded() / 100
let read = { () -> UITraits in
let category = UIApplication.sharedApplication?.preferredContentSizeCategory
?? UIScreen.main.traitCollection.preferredContentSizeCategory
let scaledValue = UIFontMetrics.default.scaledValue(for: 16.0)

return UITraits(
interfaceStyle: interfaceStyleToken(
for: UIScreen.main.traitCollection.userInterfaceStyle
),
fontSize: Int(scaledValue.rounded()),
fontScale: ((scaledValue / 16.0) * 100).rounded() / 100,
preferredContentSizeCategory: contentSizeCategoryToken(for: category)
)
}
return Thread.isMainThread ? read() : DispatchQueue.main.sync(execute: read)
#endif
}

var preferredContentSizeCategory: String {
#if os(visionOS)
return "unspecified"
#else
let category = UIApplication.sharedApplication?.preferredContentSizeCategory
?? UIScreen.main.traitCollection.preferredContentSizeCategory
return Self.contentSizeCategoryToken(for: category)
/// Refreshes the cached traits when the user changes their text size, or when the
/// app becomes active after an appearance change. Both notifications are posted on
/// the main thread.
///
/// Activation also retries ``registerForTraitChanges()``, since a window is more
/// likely to exist by then than when `DeviceHelper` was created.
private func observeUITraitChanges() {
#if !os(visionOS)
let names: [Notification.Name] = [
UIContentSizeCategory.didChangeNotification,
UIApplication.didBecomeActiveNotification
]
Comment thread
yusuftor marked this conversation as resolved.
Comment thread
pullfrog[bot] marked this conversation as resolved.
traitObservers = names.map { name in
NotificationCenter.default.addObserver(
forName: name,
object: nil,
queue: .main
) { [weak self] _ in
self?.uiTraits = DeviceHelper.makeUITraits()
self?.registerForTraitChanges()
}
}
#endif
}

/// Maps a `UIUserInterfaceStyle` to the fixed dashboard token string used by
/// backend audience filters. These tokens are a backend contract and must not change.
static func interfaceStyleToken(for style: UIUserInterfaceStyle) -> String {
switch style {
case .unspecified:
return "Unspecified"
case .light:
return "Light"
case .dark:
return "Dark"
default:
return "Unknown"
}
}

/// Maps a `UIContentSizeCategory` to the fixed dashboard token string used by
/// backend audience filters. These tokens are a backend contract and must not change.
static func contentSizeCategoryToken(for category: UIContentSizeCategory) -> String {
Expand Down Expand Up @@ -596,6 +760,16 @@ class DeviceHelper {
self.screenWidth = screenMetrics.width
self.screenHeight = screenMetrics.height
self.devicePixelRatio = screenMetrics.scale

self.uiTraits = Self.makeUITraits()
observeUITraitChanges()
registerForTraitChanges()
}

deinit {
for observer in traitObservers {
NotificationCenter.default.removeObserver(observer)
}
}

func getEnrichment(
Expand Down Expand Up @@ -631,6 +805,14 @@ class DeviceHelper {
let identityInfo = await factory.makeIdentityInfo()
let aliases = [identityInfo.aliasId]

// Snapshot once. The four trait-derived fields below each go through
// `currentUITraits`, which before iOS 17 reads live behind a blocking
// main-queue hop, so reading them separately would make four of those per call.
// Taking the snapshot means `interfaceStyle` below resolves the override
// inline rather than going through ``interfaceStyle``, so the two resolve it
// the same way by hand — keep them in step.
let traits = currentUITraits

let template = DeviceTemplate(
publicApiKey: storage.apiKey,
platform: isMac ? "macOS" : "iOS",
Expand All @@ -652,10 +834,10 @@ class DeviceHelper {
interfaceType: interfaceType,
timezoneOffset: Int(TimeZone.current.secondsFromGMT()),
radioType: radioType,
interfaceStyle: interfaceStyle,
fontSize: fontSize,
fontScale: fontScale,
preferredContentSizeCategory: preferredContentSizeCategory,
interfaceStyle: interfaceStyleOverride?.description ?? traits.interfaceStyle,
Comment thread
pullfrog[bot] marked this conversation as resolved.
fontSize: traits.fontSize,
fontScale: traits.fontScale,
preferredContentSizeCategory: traits.preferredContentSizeCategory,
isLowPowerModeEnabled: isLowPowerModeEnabled == "true",
isApplePayAvailable: true,
bundleId: bundleId,
Expand Down
Loading
Loading