UPDATE: It appears that this behavior is not allowed in iOS, despite popular apps like ChatGPT being allowed to do it... To bypass this, I now just pass the URL over to the app when the app icon is clicked from the share sheet, and then I send a notification to the user that tells them to click it to continue. I think this is legal behavior under Apple's dev guidelines, but who knows anymore.
====================================================================
I've been trying to implement a simple feature: when a user shares a webpage via iOS's share sheet, clicking my app's Action Extension should launch the app and pass along the current URL for display. I set up a custom URL scheme ("myapp") in my URL Types. For example, when I type myapp://share?url=example in Safari, I get a prompt to open my app, and after I confirm, the URL is correctly passed and displayed in my app.
However, everytime I click the Action Extension, opening the app via the deeplink fails. For example, here is what is printed:
Action Extension: Deep Link formed: myapp://share?url=www.apple
Action Extension: Failed to open main app
Main App
import SwiftUI
import SwiftData
// You can keep NavigationManager here or move it to its own file.
class NavigationManager: ObservableObject {
static let shared = NavigationManager()
@Published var sharedURL: URL? = nil
func navigateToURL(_ url: URL) {
sharedURL = url
}
}
// The AppDelegate that handles deep linking
import UIKit
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
print("AppDelegate triggered with URL: \(url)")
// (Your URL parsing and handling code follows here)
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
components.scheme == "myapp",
components.host == "share",
let queryItem = components.queryItems?.first(where: { $0.name == "url" }),
let sharedURLString = queryItem.value,
let sharedURL = URL(string: sharedURLString) else {
return false
}
NavigationManager.shared.navigateToURL(sharedURL)
return true
}
}
@main
struct MyApp: App {
// Connect your AppDelegate to the SwiftUI lifecycle.
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var navManager = NavigationManager.shared
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(navManager)
.onOpenURL { url in
// Optional fallback if needed; the AppDelegate should already handle it.
handleSharedURL(url)
}
}
}
private func handleSharedURL(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
components.scheme == "myapp",
components.host == "share",
let queryItem = components.queryItems?.first(where: { $0.name == "url" }),
let sharedURLString = queryItem.value,
let sharedURL = URL(string: sharedURLString) else {
return
}
navManager.navigateToURL(sharedURL)
}
}
Action View Controller
import UIKit
import UniformTypeIdentifiers
class ActionViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Optionally hide the view if you want no visible UI
view.isHidden = true
processInput()
}
private func processInput() {
print("Action Extension: processInput started")
// Get the first input item
guard let inputItem = extensionContext?.inputItems.first as? NSExtensionItem else {
completeRequest()
return
}
// Try to extract a URL from the item
let urlType = UTType.url.identifier
if let attachment = inputItem.attachments?.first,
attachment.hasItemConformingToTypeIdentifier(urlType) {
attachment.loadItem(forTypeIdentifier: urlType, options: nil) { [weak self] data, error in
guard let self = self else { return }
if let url = data as? URL {
self.handleURL(url)
} else {
print("Action Extension: Failed to load URL from attachment")
selfpleteRequest()
}
}
} else {
print("Action Extension: No valid attachment found")
completeRequest()
}
}
private func handleURL(_ url: URL) {
// Encode the URL to form a proper deep link
guard let encodedURL = url.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let deepLink = URL(string: "myapp://share?url=\(encodedURL)") else {
print("Action Extension: Error forming deep link")
completeRequest()
return
}
print("Action Extension: Deep Link formed: \(deepLink)")
// Attempt to open the main app with the deep link
extensionContext?.open(deepLink) { success in
if success {
print("Action Extension: Opened main app successfully")
} else {
print("Action Extension: Failed to open main app")
}
selfpleteRequest()
}
}
private func completeRequest() {
// Dismiss the extension
extensionContext?pleteRequest(returningItems: nil, completionHandler: nil)
}
}
Action Extension plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ".0.dtd">
<plist version="1.0">
<dict>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>myapp</string>
</array>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<string>TRUEPREDICATE</string>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.ui-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ActionViewController</string>
</dict>
</dict>
</plist>
Action Request Handler
import UIKit
import MobileCoreServices
import UniformTypeIdentifiers
class ActionRequestHandler: NSObject, NSExtensionRequestHandling {
var extensionContext: NSExtensionContext?
func beginRequest(with context: NSExtensionContext) {
// Do not call super in an Action extension with no user interface
self.extensionContext = context
var found = false
// Find the item containing the results from the JavaScript preprocessing.
outer:
for item in context.inputItems as! [NSExtensionItem] {
if let attachments = item.attachments {
for itemProvider in attachments {
if itemProvider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) {
itemProvider.loadItem(forTypeIdentifier: UTType.propertyList.identifier, options: nil, completionHandler: { (item, error) in
let dictionary = item as! [String: Any]
OperationQueue.main.addOperation {
self.itemLoadCompletedWithPreprocessingResults(dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as! [String: Any]? ?? [:])
}
})
found = true
break outer
}
}
}
}
if !found {
self.doneWithResults(nil)
}
}
func itemLoadCompletedWithPreprocessingResults(_ javaScriptPreprocessingResults: [String: Any]) {
// Here, do something, potentially asynchronously, with the preprocessing
// results.
// In this very simple example, the JavaScript will have passed us the
// current background color style, if there is one. We will construct a
// dictionary to send back with a desired new background color style.
let bgColor: Any? = javaScriptPreprocessingResults["currentBackgroundColor"]
if bgColor == nil || bgColor! as! String == "" {
// No specific background color? Request setting the background to red.
self.doneWithResults(["newBackgroundColor": "red"])
} else {
// Specific background color is set? Request replacing it with green.
self.doneWithResults(["newBackgroundColor": "green"])
}
}
func doneWithResults(_ resultsForJavaScriptFinalizeArg: [String: Any]?) {
if let resultsForJavaScriptFinalize = resultsForJavaScriptFinalizeArg {
// Construct an NSExtensionItem of the appropriate type to return our
// results dictionary in.
// These will be used as the arguments to the JavaScript finalize()
// method.
let resultsDictionary = [NSExtensionJavaScriptFinalizeArgumentKey: resultsForJavaScriptFinalize]
let resultsProvider = NSItemProvider(item: resultsDictionary as NSDictionary, typeIdentifier: UTType.propertyList.identifier)
let resultsItem = NSExtensionItem()
resultsItem.attachments = [resultsProvider]
// Signal that we're complete, returning our results.
self.extensionContext!pleteRequest(returningItems: [resultsItem], completionHandler: nil)
} else {
// We still need to signal that we're done even if we have nothing to
// pass back.
self.extensionContext!pleteRequest(returningItems: [], completionHandler: nil)
}
// Don't hold on to this after we finished with it.
self.extensionContext = nil
}
}
Main App plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ".0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.elislothower.URLDisplayApp</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
<key>LSApplicationQueriesSchemes</key>
<array/>
</dict>
</plist>