IceCubes/IceCubesApp/App/SafariRouter.swift
Nathan Reed 8038e8e6af
Improve deep link handling on cold start (#2026)
Previously, if the app was not already running when the Safari action extension was used to open a post in the app, the post would open in the in-app Safari instead of using the Ice Cubes UI.
The action extension only worked well if Ice Cubes was already running but backgrounded when it was used.
This was because of the `hasConnection(with:)` check used to ensure that the current server has a federation relationship with the server the post is on.
Early in app launch, the list of federated peers has not come back from the API request yet, so `hasConnection(with:)` was always returning `false`.

To fix, issue a request to fetch the peers as part of the URL handling process, before checking `hasConnection(with:)` to make the final navigation decision.
As an optimization, only do this if `hasConnection(with:)` returns `false` initially -- if it returns `true`, we already know a connection exists so no need to check again.
2024-04-02 08:26:58 +02:00

168 lines
4.9 KiB
Swift

import DesignSystem
import Env
import Models
import Observation
import SafariServices
import SwiftUI
extension View {
@MainActor func withSafariRouter() -> some View {
modifier(SafariRouter())
}
}
@MainActor
private struct SafariRouter: ViewModifier {
@Environment(Theme.self) private var theme
@Environment(UserPreferences.self) private var preferences
@Environment(RouterPath.self) private var routerPath
#if !os(visionOS)
@State private var safariManager = InAppSafariManager()
#endif
func body(content: Content) -> some View {
content
.environment(\.openURL, OpenURLAction { url in
// Open internal URL.
routerPath.handle(url: url)
})
.onOpenURL { url in
// Open external URL (from icecubesapp://)
let urlString = url.absoluteString.replacingOccurrences(of: AppInfo.scheme, with: "https://")
guard let url = URL(string: urlString), url.host != nil else { return }
_ = routerPath.handleDeepLink(url: url)
}
.onAppear {
routerPath.urlHandler = { url in
if url.absoluteString.contains("@twitter.com"), url.absoluteString.hasPrefix("mailto:") {
let username = url.absoluteString
.replacingOccurrences(of: "@twitter.com", with: "")
.replacingOccurrences(of: "mailto:", with: "")
let twitterLink = "https://twitter.com/\(username)"
if let url = URL(string: twitterLink) {
UIApplication.shared.open(url)
return .handled
}
}
#if !targetEnvironment(macCatalyst)
guard preferences.preferredBrowser == .inAppSafari else { return .systemAction }
// SFSafariViewController only supports initial URLs with http:// or https:// schemes.
guard let scheme = url.scheme, ["https", "http"].contains(scheme.lowercased()) else {
return .systemAction
}
#if os(visionOS)
return .systemAction
#else
return safariManager.open(url)
#endif
#else
return .systemAction
#endif
}
}
#if !os(visionOS)
.background {
WindowReader { window in
safariManager.windowScene = window.windowScene
}
}
#endif
}
}
#if !os(visionOS)
@MainActor
@Observable private class InAppSafariManager: NSObject, SFSafariViewControllerDelegate {
var windowScene: UIWindowScene?
let viewController: UIViewController = .init()
var window: UIWindow?
@MainActor
func open(_ url: URL) -> OpenURLAction.Result {
guard let windowScene else { return .systemAction }
window = setupWindow(windowScene: windowScene)
let configuration = SFSafariViewController.Configuration()
configuration.entersReaderIfAvailable = UserPreferences.shared.inAppBrowserReaderView
let safari = SFSafariViewController(url: url, configuration: configuration)
safari.preferredBarTintColor = UIColor(Theme.shared.primaryBackgroundColor)
safari.preferredControlTintColor = UIColor(Theme.shared.tintColor)
safari.delegate = self
DispatchQueue.main.async { [weak self] in
self?.viewController.present(safari, animated: true)
}
return .handled
}
func setupWindow(windowScene: UIWindowScene) -> UIWindow {
let window = window ?? UIWindow(windowScene: windowScene)
window.rootViewController = viewController
window.makeKeyAndVisible()
switch Theme.shared.selectedScheme {
case .dark:
window.overrideUserInterfaceStyle = .dark
case .light:
window.overrideUserInterfaceStyle = .light
}
self.window = window
return window
}
nonisolated func safariViewControllerDidFinish(_: SFSafariViewController) {
Task { @MainActor in
window?.resignKey()
window?.isHidden = false
window = nil
}
}
}
#endif
private struct WindowReader: UIViewRepresentable {
var onUpdate: (UIWindow) -> Void
func makeUIView(context _: Context) -> InjectView {
InjectView(onUpdate: onUpdate)
}
func updateUIView(_: InjectView, context _: Context) {}
class InjectView: UIView {
var onUpdate: (UIWindow) -> Void
init(onUpdate: @escaping (UIWindow) -> Void) {
self.onUpdate = onUpdate
super.init(frame: .zero)
isHidden = true
isUserInteractionEnabled = false
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func willMove(toWindow newWindow: UIWindow?) {
super.willMove(toWindow: newWindow)
if let window = newWindow {
onUpdate(window)
} else {
DispatchQueue.main.async { [weak self] in
if let window = self?.window {
self?.onUpdate(window)
}
}
}
}
}
}