2023年11月28日

|

⚠️ English version is translated by ChatGPT. See Original Chinese version here

Exploring SwiftUI Link

Part 1 - Link

Recently, when using Link, I found that it fails to open links on watchOS and instead displays a popup suggesting to open the url on the iPhone.

Although later I discovered a workaround using ASWebAuthenticationSession to bypass this issue, it sparked my curiosity about the implementation of Link and what we can achieve using it.

#if os(watchOS)
import AuthenticationServices

/// A workaround to open link in watchOS platform
struct WatchLink<Label>: View where Label: View {
    init(destination: URL, @ViewBuilder label: () -> Label) {
        self.destination = destination
        self.label = label()
    }

    let destination: URL
    let label: Label

    var body: some View {
        Button {
            let session = ASWebAuthenticationSession(
                url: destination,
                callbackURLScheme: nil
            ) { _, _ in
            }
            session.prefersEphemeralWebBrowserSession = true
            session.start()
        } label: {
            label
        }
    }
}
typealias Link = WatchLink
#endif

Code from this PR

Through Swift's reflection mechanism and examining SwiftUI.swiftinterface, we can obtain the following information about Link.

public struct Link: View {
    var label: Label
    var destination: LinkDestination
    public var body: some View {
        Button {
            destination.open()
        } label: {
            label
        }
    }
}

struct LinkDestination {
    struct Configuration {
        var url: URL
        var isSensitive: Bool
    }
    var configuration: Configuration
    @Environment(\.openURL)
    private var openURL: OpenURLAction
    @Environment(\._openSensitiveURL)
    private var openSensitiveURL: OpenURLAction

    func open() {
        let openURLAction = configuration.isSensitive ? openSensitiveURL : openURL
        openURLAction(configuration.url)
    }

    public init(destination: URL, @ViewBuilder label: () -> Label) {
        self.label = label()
        self.destination = LinkDestination(configuration: .init(url: destination, isSensitive: false))
    }
}

In simple terms, the essence of Link is to use @Environment(\.openURL) to obtain the OpenURLAction for invocation.

What caught my attention is the presence of @Environment(\._openSensitiveURL). Currently, in the exposed Link API of SwiftUI, there is no scenario where isSensitive is set to true.

So, how do we test/use openSensitiveURL?

Part 2 - openSensitiveURL

The first method is quite straightforward: since LinkDestination internally uses @Environment(\._openSensitiveURL), it suggests that SwiftUI internally has something similar as the following code, it just does not be exposed to our third-party developer.

extension EnvironmentValues {
    var _openSensitiveURL: OpenURLAction {
        get { ... }
        set { ... }
    }
}

We can directly modify the corresponding SwiftUI.swiftinterface in the SDK to add this API. In fact, for _openSensitiveURL case here, SwiftUI.swiftinterface has already marked it as public, so we don't need to make any modifications to use it.

// SwiftUI.swiftinterface
@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
extension SwiftUI.EnvironmentValues {
  public var openURL: SwiftUI.OpenURLAction {
    get
    @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
    set
  }
  public var _openURL: SwiftUI.OpenURLAction {
    get
    set
  }
}
extension SwiftUI.EnvironmentValues {
  @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
  public var _openSensitiveURL: SwiftUI.OpenURLAction {
    get
    set
  }
}

Once we have access to this API, we can directly call OpenURLAction.callAsFunction(_:) for testing.

The second method is more versatile: we use pointers provided by Unsafe Swift for direct manipulation of isSensitive.

First, convert the SwiftUI.Link object to an UnsafeMutable*Pointer, then cast it to our custom-aligned DemoLink type which matches the layout of SwiftUI.Link. Finally, we mutate the pointee to true and return the original link object to the SwiftUI system.

let url = URL(string: "https://example.com")!
var link = SwiftUI.Link(destination: url) {
    SwiftUI.Text("Example")
}
withUnsafeMutablePointer(to: &link) { pointer in
    let linkPointer = UnsafeMutableRawPointer(pointer).assumingMemoryBound(to: DemoLink<DemoText>.self)
    let isSensitivePointer = linkPointer.pointer(to: \.destination.configuration.isSensitive)!
    isSensitivePointer.pointee = true
}
return link

Part 3 - OpenSensitiveURLActionKey

After testing, it was found that for regular URLs (such as https://example.com), both openURL and _openSensitiveURL can open them. However, for sensitive URLs (such as the Setting's app privacy page schema prefs:root=Privacy), neither of them can open them. The error log is as follows:


Failed to open URL prefs:root=Privacy&path=contacts:
Error Domain=NSOSStatusErrorDomain
Code=-10814 "(null)"
UserInfo={
    _LSLine=225,
    _LSFunction=-[_LSDOpenClient openURL:options:completionHandler:]
}

The implementation for them on the iOS platform goes through the open method of UIApplication and the open method of LSApplicationWorkspace.

struct OpenURLActionKey: EnvironmentKey {
    static let defaultValue = OpenURLAction(
        handler: .system { url, completion in
            UIApplication.shared.open(url, options: [:], completionHandler: completion)
        },
        isDefault: false
    )
}

struct OpenSensitiveURLActionKey: EnvironmentKey {
    static let defaultValue = OpenURLAction(
        handler: .system { url, completion in
            let config = _LSOpenConfiguration()
            config.isSensitive = true
            let scene = UIApplication.shared.connectedScenes.first
            config.targetConnectionEndpoint = scene?._currentOpenApplicationEndpoint
            guard let workspace = LSApplicationWorkspace.default() else {
                return
            }
            workspace.open(url, configuration: config, completionHandler: completion)
        },
        isDefault: false
    )
}

The inability to open the former is as expected. For the latter, further investiation revealed that it is due to the absence of the com.apple.springboard.opensensitiveurl entitlement.

After adding this entitlement and running on the simulator, openSensitiveURL(url) successfully navigates to the corresponding page.

Linking to Private Framework

OpenSensitiveURLActionKey
on iOS relies heavily on private APIs.

For a normal mockup implementation of iOS, it is recommended to use the ObjectiveC Runtime's message mechanism (NSStringFromClass, performSelector etc.).

For the macOS platform, we can easily link to the Private Framework by adding the relevant flags and system library search paths.

(An example can be referenced from my previous macOS Keychain Data Extraction Package)

However, the private frameworks on the iOS platform do not exist in our macOS development environment. So adding header as a fake framework can pass the compiler but not the linker.

And in the case of OpenSwiftUI, the implementation of OpenSensitiveURLActionKey on iOS actually uses the macOS approach and works. But it was just coincidentally and not scale here.

The reason is as following: 1. The dependent CoreServices framework on macOS happens to have the same private framework and corresponding implementation. 2. A class in the dependent BoardServices is only used for parameter passing and can be processed as id/Any. 3. UIScene is defined in UIKit which is part of the public SDK. And our fake UIKitCore framework only extend a method, so linking of UIScene would not require UIKitCore.

Part 4 - Conclusion

Returning to the initial question of Link issues on watchOS, the problem arises from the implementation of OpenURLActionKey.defaultValue on watchOS.

Compared to typealias Link = WatchLink, a better solution to the original problem is override @Environment(\.openURL).

  • Seamless use of other APIs of Link, eliminating the need to duplicate implementations for WatchLink.
  • Enjoying the built-in accessibility support of Link (or forwarding accessibility behavior to Link using accessibilityRepresentation).
  • Working through a runtime-based Environment system rather than compile-time typealias.
#if os(watchOS)
import AuthenticationServices
extension OpenURLAction {
    static let authenticationSessionAction = OpenURLAction {
        let session = ASWebAuthenticationSession(
            url: $0,
            callbackURLScheme: nil
        ) { _, _ in
        }
        session.prefersEphemeralWebBrowserSession = true
        session.start()
        return .handled
    }
}
#endif

struct ContentView: View {
    var body: some View {
        ...
        #if os(watchOS)
        .environment(\.openURL, .authenticationSessionAction)
        #endif
    }
}

Through the exploration of SwiftUI Link in this article, I hope it would be helpful for those struggling SwiftUI.Link

References

  • The complete code related to Link implementation can be found in this PR.