RedditWebView.swift 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. import AppKit
  2. import SwiftUI
  3. import WebKit
  4. struct RedditWebView: NSViewRepresentable {
  5. let url: URL
  6. let reloadTrigger: Int
  7. func makeCoordinator() -> Coordinator {
  8. Coordinator()
  9. }
  10. func makeNSView(context: Context) -> RedditWebViewContainer {
  11. let container = RedditWebViewContainer(webView: RedditWebViewManager.makeWebView())
  12. container.webView.navigationDelegate = context.coordinator
  13. container.webView.uiDelegate = context.coordinator
  14. context.coordinator.container = container
  15. context.coordinator.lastReloadTrigger = reloadTrigger
  16. container.load(url: url)
  17. return container
  18. }
  19. func updateNSView(_ container: RedditWebViewContainer, context: Context) {
  20. guard context.coordinator.lastReloadTrigger != reloadTrigger else { return }
  21. context.coordinator.lastReloadTrigger = reloadTrigger
  22. container.load(url: url)
  23. }
  24. final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
  25. weak var container: RedditWebViewContainer?
  26. var lastReloadTrigger = -1
  27. func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
  28. webView.evaluateJavaScript(RedditPostBoundaryStyle.injectionScript, completionHandler: nil)
  29. }
  30. func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
  31. container?.reloadLastURL()
  32. }
  33. func webView(
  34. _ webView: WKWebView,
  35. didFailProvisionalNavigation navigation: WKNavigation!,
  36. withError error: Error
  37. ) {
  38. let nsError = error as NSError
  39. guard nsError.domain != NSURLErrorDomain || nsError.code != NSURLErrorCancelled else { return }
  40. container?.reloadLastURL(after: 1.0)
  41. }
  42. func webView(
  43. _ webView: WKWebView,
  44. decidePolicyFor navigationAction: WKNavigationAction,
  45. decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
  46. ) {
  47. guard let url = navigationAction.request.url else {
  48. decisionHandler(.allow)
  49. return
  50. }
  51. if navigationAction.targetFrame == nil {
  52. webView.load(URLRequest(url: url))
  53. decisionHandler(.cancel)
  54. return
  55. }
  56. if navigationAction.navigationType == .linkActivated,
  57. !Self.isRedditURL(url) {
  58. NSWorkspace.shared.open(url)
  59. decisionHandler(.cancel)
  60. return
  61. }
  62. decisionHandler(.allow)
  63. }
  64. func webView(
  65. _ webView: WKWebView,
  66. createWebViewWith configuration: WKWebViewConfiguration,
  67. for navigationAction: WKNavigationAction,
  68. windowFeatures: WKWindowFeatures
  69. ) -> WKWebView? {
  70. if let url = navigationAction.request.url {
  71. webView.load(URLRequest(url: url))
  72. }
  73. return nil
  74. }
  75. private static func isRedditURL(_ url: URL) -> Bool {
  76. guard let host = url.host?.lowercased() else { return false }
  77. let redditHosts = [
  78. "reddit.com",
  79. "www.reddit.com",
  80. "old.reddit.com",
  81. "new.reddit.com",
  82. "reddit.app.link",
  83. "redd.it",
  84. ]
  85. return redditHosts.contains(where: { host == $0 || host.hasSuffix(".\($0)") })
  86. }
  87. }
  88. }
  89. final class RedditWebViewContainer: NSView {
  90. let webView: WKWebView
  91. private var pendingURL: URL?
  92. private var lastLoadedURL: URL?
  93. private var reloadWorkItem: DispatchWorkItem?
  94. init(webView: WKWebView) {
  95. self.webView = webView
  96. super.init(frame: .zero)
  97. wantsLayer = true
  98. addSubview(webView)
  99. webView.translatesAutoresizingMaskIntoConstraints = false
  100. NSLayoutConstraint.activate([
  101. webView.leadingAnchor.constraint(equalTo: leadingAnchor),
  102. webView.trailingAnchor.constraint(equalTo: trailingAnchor),
  103. webView.topAnchor.constraint(equalTo: topAnchor),
  104. webView.bottomAnchor.constraint(equalTo: bottomAnchor),
  105. ])
  106. }
  107. @available(*, unavailable)
  108. required init?(coder: NSCoder) {
  109. fatalError("init(coder:) has not been implemented")
  110. }
  111. func load(url: URL) {
  112. pendingURL = url
  113. lastLoadedURL = url
  114. reloadWorkItem?.cancel()
  115. tryLoadIfReady()
  116. }
  117. func reloadLastURL(after delay: TimeInterval = 0) {
  118. guard let lastLoadedURL else { return }
  119. reloadWorkItem?.cancel()
  120. let workItem = DispatchWorkItem { [weak self] in
  121. self?.load(url: lastLoadedURL)
  122. }
  123. reloadWorkItem = workItem
  124. DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
  125. }
  126. override func layout() {
  127. super.layout()
  128. tryLoadIfReady()
  129. }
  130. override func viewDidMoveToWindow() {
  131. super.viewDidMoveToWindow()
  132. tryLoadIfReady()
  133. }
  134. private func tryLoadIfReady() {
  135. guard let url = pendingURL else { return }
  136. guard bounds.width > 1, bounds.height > 1 else { return }
  137. pendingURL = nil
  138. webView.load(URLRequest(url: url))
  139. }
  140. }