logo hsb.horse
← Back to blog index

Blog

Implement Unlock Handoff from Shield Extension to the Main App via App Group

You cannot launch the main app directly from ShieldActionDelegate. This explains a handoff pattern where the extension writes an unlock request into App Group shared storage and the app reads it later.

Published:

When you write button handling inside ShieldActionDelegate, the moment comes where you want to open the main app from there and show an unlock screen. But you cannot write that flow in a straightforward way. If you are new to iOS, it is one of those areas where “why this is impossible” is hard to understand, so I want to start from the background.

Why an Extension cannot open the app directly

ShieldActionDelegate can only return two values: .close and .defer. On other platforms, this is where you might use a URL scheme to launch the app, but in the shield context, that route does not work.

Apple is likely closing this path to preserve the consistency of the shield experience. If the shield becomes a loophole, blocking loses its meaning. If extensions were allowed to jump into the main app directly, poorly designed code could remove the restriction from inside the restriction itself. This is broadly true for iOS extensions: direct control over the main app is generally limited.

A handoff pattern through App Group

The realistic implementation is an asynchronous handoff through shared storage.

App Group is the mechanism that lets the main app and an extension share data. Normally, app processes on iOS cannot access each other’s data, but if they belong to the same App Group, they can read and write shared UserDefaults or a shared file container.

On the Shield Action Extension side, write a flag into the App Group shared container that says an unlock request is pending. When the user later opens the app manually, the main app reads that flag and starts the unlock flow.

// Inside Shield Action Extension
override func handle(
action: ShieldAction,
for webDomain: WebDomainToken,
completionHandler: @escaping (ShieldActionResponse) -> Void
) {
let defaults = UserDefaults(suiteName: "group.com.example.stoicdns")
defaults?.set(true, forKey: "pendingUnlockRequest")
defaults?.set(Date(), forKey: "unlockRequestedAt")
completionHandler(.close)
}
// On app launch / when returning to foreground
func checkPendingUnlock() {
let defaults = UserDefaults(suiteName: "group.com.example.stoicdns")
guard defaults?.bool(forKey: "pendingUnlockRequest") == true else { return }
defaults?.removeObject(forKey: "pendingUnlockRequest")
showUnlockFlow()
}

The shield closes with .close. The user has to return to the Home Screen once, then open the app manually.

Why save a timestamp together with the request

If you store the request time at the same moment, it becomes easier to ignore stale requests. A rule like “requests older than 30 minutes are invalid” prevents the app from unexpectedly showing an unlock screen long after the user has forgotten about it.

Accept it as part of the UX

If you expect a “one tap from the shield directly into the unlock screen” flow, this pattern may feel unsatisfying. But this is the range Apple’s interface allows.

Seen differently, the friction of noticing the shield and then deliberately opening the app is also part of self-control. A flow with one extra pause can fit the product goal better than a one-tap escape hatch.

It is faster to accept that constraint and implement around it.