●MAX — Rork Max generates native Swift apps for iPhone, iPad, Watch, TV, Vision Pro, and iMessage●NATIVE — It reaches AR/LiDAR, Metal 3D, Dynamic Island, Live Activities, HealthKit, and Core ML●PUBLISH — Two-click App Store submission sharply cuts the overhead of shipping an app●PRICING — Rork Max is 00/month, while the original Rork starts free with paid plans from 5/month●FUNDING — Rork raised .8M from a16z, with over 743k monthly visits and 85% growth●TOOL — The original Rork builds native iOS and Android apps from plain English using React Native (Expo)●MAX — Rork Max generates native Swift apps for iPhone, iPad, Watch, TV, Vision Pro, and iMessage●NATIVE — It reaches AR/LiDAR, Metal 3D, Dynamic Island, Live Activities, HealthKit, and Core ML●PUBLISH — Two-click App Store submission sharply cuts the overhead of shipping an app●PRICING — Rork Max is 00/month, while the original Rork starts free with paid plans from 5/month●FUNDING — Rork raised .8M from a16z, with over 743k monthly visits and 85% growth●TOOL — The original Rork builds native iOS and Android apps from plain English using React Native (Expo)
Making the Block Screen Feel Like Your Own App — ShieldConfiguration and Button Handling in Rork Max
Replace the plain gray shield that screen-time apps show with something that speaks in your app's voice. This walks through swapping the UI with a ShieldConfiguration extension and controlling the buttons with ShieldAction, wired into a Rork Max native setup.
As an indie developer building a focus-time app in Rork Max, I kept hitting the same wall: the block would fire on schedule, but what appeared was Apple's default gray screen — nothing that told the user it was still my app. After a carefully made onboarding flow, users were suddenly dropped onto a cold system screen.
That "shield" screen can be replaced. But you can't touch it from your main app code. The screen shown during a block is drawn by a separate process, so reshaping it means adding two dedicated App Extensions: a ShieldConfiguration extension that swaps the appearance, and a ShieldAction extension that decides what happens when its buttons are pressed.
I'll assume you already have the Family Controls entitlement and are applying shields through ManagedSettingsStore. This article focuses on the step after that: rebuilding the screen itself.
Why you can't draw the shield from the main app
When you hand tokens to store.shield.applications, the system slides a shield screen in the moment the target app is opened. That screen isn't drawn by your app — it's a separate process running inside the ManagedSettingsUI framework. Neither React Native nor your main Swift target can touch it directly.
The one entry point is ShieldConfigurationDataSource. Right before the system shows the shield, it asks this class, and builds the screen from the ShieldConfiguration you return. So what you control is "which parts, in which colors" — not an arbitrary SwiftUI view. Miss this and you'll get stuck trying to render an elaborate layout that the API simply won't accept.
What you want
Possible?
How
Change background color / blur
Yes
backgroundColor / backgroundBlurStyle
Use your own icon
Yes (only images bundled in the extension)
icon as a UIImage
Title / subtitle text and color
Yes
title / subtitle (Label)
Two buttons
Yes (max two — label and color only)
primaryButtonLabel / secondaryButtonLabel
Render an arbitrary SwiftUI view
No
—
Fetch a remote image on the spot
Effectively no
Bundle it, or pass via App Group
Step 1: Add the ShieldConfiguration extension
In Xcode, add a new "Shield Configuration" extension target. When you build through Expo like Rork Max does, re-adding the target by hand on every eas build isn't realistic, so inject the extension target, its Info.plist, and entitlements through a config plugin. I keep this in the same config plugin as my main native module so the extension never gets forgotten.
In the extension's Info.plist, set NSExtensionPointIdentifier to com.apple.ManagedSettingsUI.shield-configuration-service and NSExtensionPrincipalClass to your class name. Get this wrong and the build still succeeds, but the system never calls you — you stay on the default screen, with no error to explain why.
import ManagedSettingsimport ManagedSettingsUIimport UIKitclass FocusShieldConfiguration: ShieldConfigurationDataSource { override func configuration(shielding application: Application) -> ShieldConfiguration { return ShieldConfiguration( backgroundBlurStyle: .systemThinMaterialDark, backgroundColor: UIColor(red: 0.08, green: 0.10, blue: 0.16, alpha: 1), icon: UIImage(named: "ShieldLeaf"), title: ShieldConfiguration.Label( text: "This is your focus time", color: .white ), subtitle: ShieldConfiguration.Label( text: "You chose to close this app during this window. Give it a few more minutes.", color: UIColor(white: 0.75, alpha: 1) ), primaryButtonLabel: ShieldConfiguration.Label( text: "Back to focus", color: .white ), primaryButtonBackgroundColor: UIColor(red: 0.20, green: 0.55, blue: 0.90, alpha: 1), secondaryButtonLabel: ShieldConfiguration.Label( text: "Open for 5 minutes", color: UIColor(white: 0.65, alpha: 1) ) ) } // Return this too when you block by category override func configuration(shielding application: Application, in category: ActivityCategory) -> ShieldConfiguration { return configuration(shielding: application) }}
Set secondaryButtonLabel to nil and you get a single button. Specific wording pays off. Rather than announcing "blocked," a line that reminds users "this was your own promise" changes how the screen lands. Just swapping "Access is blocked" for the line above visibly warmed up my review sentiment.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A full ShieldConfiguration extension that replaces the default gray shield — background, icon, title, and up to two buttons — in your app's tone
✦The ShieldAction code that decides what the block-screen buttons do: close, grant a timed pass, or ignore, with a clean state-sharing design
✦Practical notes on the out-of-process, low-memory constraints of these extensions: bundling images, passing state via App Group, and what review looks at
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Step 2: Decide what the buttons do with ShieldAction
Changing the look still leaves the button behavior up to the system. The second extension, ShieldActionDelegate, is where you take that over. Its Info.plistNSExtensionPointIdentifier is com.apple.ManagedSettings.shield-action-service — a different value from the UI extension above.
import ManagedSettingsimport Foundationclass FocusShieldAction: ShieldActionDelegate { private let appGroup = "group.com.example.focus.shield" override func handle(action: ShieldAction, for application: ApplicationToken, completionHandler: @escaping (ShieldActionResponse) -> Void) { switch action { case .primaryButtonPressed: // "Back to focus" -> just close the screen completionHandler(.close) case .secondaryButtonPressed: // "Open for 5 minutes" -> record a grace window, then defer grantTemporaryPass(minutes: 5) completionHandler(.defer) @unknown default: completionHandler(.none) } } private func grantTemporaryPass(minutes: Int) { let defaults = UserDefaults(suiteName: appGroup) let until = Date().addingTimeInterval(TimeInterval(minutes * 60)) defaults?.set(until.timeIntervalSince1970, forKey: "passUntil") // The actual unshielding happens in the monitor (DeviceActivityMonitor), reading passUntil }}
There are only three responses. .close dismisses the shield and sends the user home, .defer pulls the screen back and hands off to re-evaluation, and .none does nothing.
Response
Screen behavior
When to use
.close
Dismiss the shield, return to home screen
When you want "not now" to mean stop
.defer
Pull the screen back, re-evaluate state
When granting a temporary pass
.none
Stay in place
Ignore mistaps, guard against double presses
The key is to accept that the ShieldAction extension itself has no authority to lift the shield. The one that actually runs store.shield.applications = nil is the DeviceActivityMonitor that watches the schedule. The button extension only writes its intent — "grant five minutes" — into the App Group, and the monitor reads passUntil and decides whether to unshield. Splitting the roles this way keeps things from breaking no matter which process happens to be awake. The button extension wakes for an instant and dies immediately, so loading it with heavy work or unshield logic drops the ball.
Constraints that come from running out of process
Both extensions run in a separate, low-memory, short-lived process from the main app. Miss this and you get the nasty kind of bug that works in the simulator but falls apart on device or in release builds. Here are the spots I actually tripped on.
Images first. UIImage(named:) reads the extension target's own bundle, not the main app's asset catalog. Your icon has to be included in the extension target too, or the asset catalog set to a shared target. Forget this and icon silently becomes nil, and the default icon shows.
Network next. Even if you try to fetch an image or copy remotely on the spot, the extension runs for such a short time that it can't wait for the request to finish. Anything you want to vary dynamically should be written to the App Group while the main app is running; the extension just reads it.
And all state sharing goes through the App Group. If the App Group name differs by a single character, UserDefaults(suiteName:) returns nil without throwing, so you find out late. I make copy-pasting the App Group across both targets from the Capability screen a standing check every time.
What review and users look at
Apps using Family Controls face a purpose review for the entitlement itself. If you're rebuilding the shield, here's one thing I'd honestly recommend: don't put wording on the shield that coaches users to bypass the block, or that unfairly disparages other apps. The shield is a place to remind users of a limit they chose — not a place to insert your pitch. Keeping the copy calm, quietly supporting the promise they made, is safer both for review and for user trust.
Leave an escape hatch, or close it
Whether you leave an escape hatch like "open for 5 minutes" depends on the app's character. A parental-control app should close the hatch; a focus app for self-discipline tends to keep people longer if it offers the hatch and then, later, shows "how many times did you use it today." In my case, tallying grace uses into the App Group and gently reflecting them back the next morning is what made retention noticeably steadier. On the revenue side, apps like this keep subscribers less through the strength of the block and more through the felt sense of having followed through. In my own apps, a small subscription built on that follow-through has been a longer-lived number on the App Store than leaning on AdMob ad revenue.
The shield is a surprisingly important touchpoint that users see many times a day. Don't leave it as the default — let it speak in your app's voice. It costs you two extensions, but the difference in impression is well worth it.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.