本文首发于个人博客 kyleye.top

初探 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 的同学带来一点点帮助

参阅