Skip to content

Marshal iOS appIcon read to main thread to fix first-render tint race#192

Merged
NghiaTranUIT merged 1 commit into
ProxymanApp:mainfrom
ptrkstr:fix/ios-appicon-main-thread
May 28, 2026
Merged

Marshal iOS appIcon read to main thread to fix first-render tint race#192
NghiaTranUIT merged 1 commit into
ProxymanApp:mainfrom
ptrkstr:fix/ios-appicon-main-thread

Conversation

@ptrkstr

@ptrkstr ptrkstr commented May 28, 2026

Copy link
Copy Markdown
Contributor

Summary

On iOS / tvOS / visionOS, Image.appIcon in Sources/Packages.swift calls UIImage(named:) directly. ConnectionPackage(config:) is constructed on the transporter queue once the Proxyman connection becomes .ready, so that call ends up running on a background thread.

During a SwiftUI app's first UIWindow automatic tintColor resolution, an off-main UIImage(named:) races with UIKit's own AccentColor asset lookup. The race leaves the window's inherited tintColor at the iOS system blue default (0, 0.533, 1, 1), which then bakes into UITransitionView and every descendant of the initial render. Any SwiftUI view consuming .tint (Image(systemName:).foregroundStyle(.tint), .buttonStyle(.borderedProminent), .glassProminent, and so on) ends up rendering in system blue on first launch instead of the app's AccentColor.

The bug only shows up when Proxyman is actually running, because the off-main read happens in the connection-ready callback. No connection, no callback, no race.

Fix

Mirror the os(OSX) branch's DispatchQueue.main.sync hop on the iOS / tvOS / visionOS branch. The macOS branch already carries the comment "Must be called on the Main Thread / Otherwise, we get a UI Background Checker warnings". The iOS branch now does the same thing.

+23/-7 in one file, no API change.

#elseif os(iOS) || os(tvOS) || os(visionOS)
if Thread.isMainThread {
    return _resolveAppIconOnMain()
} else {
    return DispatchQueue.main.sync { _resolveAppIconOnMain() }
}

Before / after

Minimal SwiftUI app, AccentColor set to black (P3), heart icon uses .foregroundStyle(.tint), button uses .borderedProminent. Between the two screenshots, the only thing that changes is whether an off-main UIImage(named: "AppIcon60x60") runs during App.init(), which is what ConnectionPackage.init does today.

Before (off-main UIImage(named:)) After (main-thread-marshalled)
before after

Repro

Self-contained xcodegen project, no third-party deps, no Atlantis required. It exercises the UIKit race directly, so you can see the failure mode without needing Proxyman running.

TintRace.zip

unzip TintRace.zip && cd TintRace
xcodegen generate
xcodebuild -project TintRace.xcodeproj -scheme TintRace \
  -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build

Toggle static let BUG in Sources/TintRaceApp.swift to flip between the broken (off-main) and correct (no off-main) patterns. I also verified this branch against a real app with Atlantis 1.35.0 and Proxyman running: tint stays correct.

Underlying issue

This patch is a workaround for what looks like a UIKit thread-safety bug. UIImage(named:) is documented thread-safe and returns a valid image, and UIColor(named: "AccentColor") from the main thread still returns the correct color, so the asset catalog itself is not the problem. The corruption is in the first-window automatic tintColor resolution, which gets thrown off by any concurrent off-main asset-catalog access during launch. I've filed FB[TODO] with Apple. Until UIKit fixes it, the safe pattern is "don't call UIImage(named:) off-main during App.init()", which is the same constraint the macOS branch was already documenting here.

`UIImage(named:)` is documented thread-safe, but during a SwiftUI
app's first UIWindow tintColor resolution it races with UIKit's
AccentColor asset lookup. When `ConnectionPackage` is constructed
on the transporter queue (after the Proxyman connection becomes
ready) and reaches the iOS branch of `Image.appIcon`, that off-main
`UIImage(named:)` corrupts the window's inherited tintColor, baking
iOS system blue into every descendant of the initial render.

Mirror the existing OSX branch's `DispatchQueue.main.sync` hop so
the iOS / tvOS / visionOS branch also resolves the icon on the
main thread.

Repro + before/after screenshots in the PR description.
@ptrkstr ptrkstr marked this pull request as ready for review May 28, 2026 16:51

@NghiaTranUIT NghiaTranUIT left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks so much for your contribution 👍

@NghiaTranUIT NghiaTranUIT merged commit 334ee9e into ProxymanApp:main May 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants