2023年11月28日

|

初探 SwiftUI Link

起 - Link

最近在使用 Link 的时候发现在 watchOS 上无法成功打开链接,而是会弹窗提示需要在 iPhone 端打开

虽然后续发现可以通过 ASWebAuthenticationSession 来绕过打开,但还是对 Link 的实现以及我们能使用 Link 做什么产生了好奇。

#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

代码来源

通过 Swift 的反射机制和查看 SwiftUI.swiftinterface等方式,我们可以得到关于 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))
    }
}

简单的说 Link 的本质就是通过 @Environment(\.openURL) 来拿到 OpenURLAction 进行唤起打开。

引起我注意的是这里的 @Environment(\._openSensitiveURL),目前 SwiftUI 暴露出来的 Link API 中并没有将 isSensitive 设置为 true 的情况。

那我们需要如何测试/使用 openSensitiveURL 呢?

承 - openSensitiveURL

第一种方式比较直观:既然 LinkDestination 内部在使用 @Environment(\._openSensitiveURL),说明 SwiftUI 内部是有类似的如下代码,只是可能没有暴露给外部第三方使用

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

我们直接修改 SDK 中对应的 SwiftUI.swiftinterface 添加上这个 API 即可 (事实上,对于这里的 _openSensitiveURL 而言,SwiftUI.swiftinterface 已经将其标记为了 public,因此我们不需要进行任何修改即可使用)

// 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
  }
}

拿到这个 API 后我们就可以直接调用 OpenURLAction.callAsFunction(_:) 来进行测试

第二种方式更加通用:我们通过 Unsafe Swift 提供的指针操作直接修改 isSensitive

先将 SwiftUI.Link 对象转为 UnsafeMutable*Pointer,再将其 cast 到我们自定义对齐 SwiftUI.Link 布局的 DemoLink 类型,最后完成修改,并返回原 link 对象给到 SwiftUI 系统

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

转 - OpenSensitiveURLActionKey

测试后发现对于普通的 url(比如 https://example.com ),openURL_openSensitiveURL 都能打开,但是对于敏感 URL (比如设置隐私页schema prefs:root=Privacy

二者还是都无法打开,对应错误日志如下


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

二者在 iOS 平台的实现分别走了 UIApplication 的 open 方法和 LSApplicationWorkspace 的 open 方法

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
    )
}

对于前者无法打开是符合预期的,对于后者继续跟踪定位发现是由于我们缺少了 com.apple.springboard.opensensitiveurl 这项 entitlement 导致的

添加该 entitlement 后在模拟器上运行,openSensitiveURL(url) 即可成功跳转到设置隐私页。

链接 Private Framework

OpenSensitiveURLActionKey 在 iOS 上依赖大量私有 API,正常的模拟实现建议通过 ObjectiveC Runtime 的消息机制(NSStringFromClass performSelector)来实现。

对于 macOS 平台,我们可以通过添加相关 flag 和添加系统库的搜索路径来比较简单地完成对 Private Framework 的 link

(例子可以参考之前写的macOS 提取 Keychain 数据的 Package)

但是 iOS 平台的私有库在我们的编译环境 macOS 上并不存在,导致添加相关 header 封装成 framework 后可以顺利 build,但是无法完成 link

OpenSwiftUI 这里对 iOS 的 OpenSensitiveURLActionKey 实现使用了 macOS 方案并且可以正常工作只是巧合

  1. 依赖的 CoreServices 在 macOS 上刚好有同样的私有库和对应实现
  2. 依赖的 BoardServices 中的某个类只用作参数传递,使用 id/Any 即可
  3. 依赖的 UIKitCore 的 UIScene 在公开 SDK 的 UIKit 中有定义,这里只需扩展方法,无需link UIKitCore

合 - 结论

回到本文最初的问题,watchOS 上 Link 的问题是因为 OpenURLActionKey.defaultValue 在 watchOS 上的实现导致的

相比 typealias Link = WatchLink 我们通过覆盖 @Environment(\.openURL) 可以更好地解决原问题

  • 无缝使用 Link 的 其他API,而不是给 WatchLink 都重复实现一次
  • 享受 Link 自带的无障碍支持(或者使用 accessibilityRepresentation 将无障碍行为转发到 Link)
  • 基于运行时的 Environment 体系,而非编译期的 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
    }
}

通过本文的对 SwiftUI Link 的探索,希望能为不熟悉 SwiftUI.Link 的同学带来一点点帮助

参考