NetNewsWire/Mac/MainWindow/Detail/DetailWebViewController.swift
2024-03-23 11:57:38 -07:00

428 lines
14 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// DetailWebViewController.swift
// NetNewsWire
//
// Created by Brent Simmons on 2/11/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import AppKit
import WebKit
import RSWeb
import Articles
import Core
protocol DetailWebViewControllerDelegate: AnyObject {
@MainActor func mouseDidEnter(_: DetailWebViewController, link: String)
@MainActor func mouseDidExit(_: DetailWebViewController)
}
final class DetailWebViewController: NSViewController {
weak var delegate: DetailWebViewControllerDelegate?
var webView: DetailWebView!
var state: DetailState = .noSelection {
didSet {
if state != oldValue {
switch state {
case .article(_, let scrollY), .extracted(_, _, let scrollY):
windowScrollY = scrollY
default:
break
}
reloadHTML()
}
}
}
var article: Article? {
switch state {
case .article(let article, _):
return article
case .extracted(let article, _, _):
return article
default:
return nil
}
}
private var articleTextSize = AppDefaults.shared.articleTextSize
#if !MAC_APP_STORE
private var webInspectorEnabled: Bool {
get {
return webView.configuration.preferences._developerExtrasEnabled
}
set {
webView.configuration.preferences._developerExtrasEnabled = newValue
}
}
#endif
private let detailIconSchemeHandler = DetailIconSchemeHandler()
private var waitingForFirstReload = false
private let keyboardDelegate = DetailKeyboardDelegate()
private var windowScrollY: CGFloat?
private let appIsInApplicationsFolder: Bool = {
#if DEBUG
return true // We want the same experience most people have. Search in this file for appIsInApplicationsFolder for more details.
#else
let appPath = Bundle.main.bundlePath
let applicationsFolderURL = try! FileManager.default.url(for: .applicationDirectory, in: .localDomainMask, appropriateFor: nil, create: false)
let applicationsFolderPath = applicationsFolderURL.path
return appPath.hasPrefix(applicationsFolderPath)
#endif
}()
private var isShowingExtractedArticle: Bool {
switch state {
case .extracted(_, _, _):
return true
default:
return false
}
}
static let userScripts: [WKUserScript] = {
let filenames = ["main", "main_mac", "newsfoot"]
let scripts = filenames.map { filename in
let scriptURL = Bundle.main.url(forResource: filename, withExtension: ".js")!
let scriptSource = try! String(contentsOf: scriptURL)
return WKUserScript(source: scriptSource, injectionTime: .atDocumentStart, forMainFrameOnly: true)
}
return scripts
}()
private struct MessageName {
static let mouseDidEnter = "mouseDidEnter"
static let mouseDidExit = "mouseDidExit"
static let windowDidScroll = "windowDidScroll"
}
override func loadView() {
let preferences = WKPreferences()
preferences.minimumFontSize = 12.0
preferences.javaScriptCanOpenWindowsAutomatically = false
let configuration = WKWebViewConfiguration()
configuration.preferences = preferences
configuration.defaultWebpagePreferences.allowsContentJavaScript = AppDefaults.shared.isArticleContentJavascriptEnabled
configuration.setURLSchemeHandler(detailIconSchemeHandler, forURLScheme: ArticleRenderer.imageIconScheme)
configuration.mediaTypesRequiringUserActionForPlayback = .all
let userContentController = WKUserContentController()
userContentController.add(self, name: MessageName.windowDidScroll)
userContentController.add(self, name: MessageName.mouseDidEnter)
userContentController.add(self, name: MessageName.mouseDidExit)
for script in Self.userScripts {
userContentController.addUserScript(script)
}
configuration.userContentController = userContentController
webView = DetailWebView(frame: NSRect.zero, configuration: configuration)
webView.uiDelegate = self
webView.navigationDelegate = self
webView.keyboardDelegate = keyboardDelegate
webView.translatesAutoresizingMaskIntoConstraints = false
if let userAgent = UserAgent.fromInfoPlist() {
webView.customUserAgent = userAgent
}
view = webView
// Hide the web view until the first reload (navigation) is complete (plus some delay) to avoid the awful white flash that happens on the initial display in dark mode.
// See bug #901.
webView.isHidden = true
waitingForFirstReload = true
#if !MAC_APP_STORE
webInspectorEnabled = AppDefaults.shared.webInspectorEnabled
NotificationCenter.default.addObserver(self, selector: #selector(webInspectorEnabledDidChange(_:)), name: .WebInspectorEnabledDidChange, object: nil)
#endif
NotificationCenter.default.addObserver(self, selector: #selector(feedIconDidBecomeAvailable(_:)), name: .FeedIconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(currentArticleThemeDidChangeNotification(_:)), name: .CurrentArticleThemeDidChangeNotification, object: nil)
webView.loadFileURL(ArticleRenderer.blank.url, allowingReadAccessTo: ArticleRenderer.blank.baseURL)
}
// MARK: Notifications
@objc func feedIconDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func avatarDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func faviconDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func userDefaultsDidChange(_ note: Notification) {
if articleTextSize != AppDefaults.shared.articleTextSize {
articleTextSize = AppDefaults.shared.articleTextSize
reloadHTMLMaintainingScrollPosition()
}
}
@objc func currentArticleThemeDidChangeNotification(_ note: Notification) {
reloadHTMLMaintainingScrollPosition()
}
// MARK: Media Functions
func stopMediaPlayback() {
webView.evaluateJavaScript("stopMediaPlayback();")
}
// MARK: Scrolling
func canScrollDown(_ completion: @escaping (Bool) -> Void) {
fetchScrollInfo { (scrollInfo) in
completion(scrollInfo?.canScrollDown ?? false)
}
}
func canScrollUp(_ completion: @escaping (Bool) -> Void) {
fetchScrollInfo { (scrollInfo) in
completion(scrollInfo?.canScrollUp ?? false)
}
}
override func scrollPageDown(_ sender: Any?) {
webView.scrollPageDown(sender)
}
override func scrollPageUp(_ sender: Any?) {
webView.scrollPageUp(sender)
}
// MARK: State Restoration
func saveState(to state: inout [AnyHashable : Any]) {
state[UserInfoKey.isShowingExtractedArticle] = isShowingExtractedArticle
state[UserInfoKey.articleWindowScrollY] = windowScrollY
}
}
// MARK: - WKScriptMessageHandler
extension DetailWebViewController: WKScriptMessageHandler {
nonisolated func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
if message.name == MessageName.windowDidScroll {
let updatedWindowScrollY = message.body as? CGFloat
Task { @MainActor in
windowScrollY = updatedWindowScrollY
}
} else if message.name == MessageName.mouseDidEnter, let link = message.body as? String {
Task { @MainActor in
delegate?.mouseDidEnter(self, link: link)
}
} else if message.name == MessageName.mouseDidExit {
Task { @MainActor in
delegate?.mouseDidExit(self)
}
}
}
}
// MARK: - WKNavigationDelegate & WKUIDelegate
extension DetailWebViewController: WKNavigationDelegate, WKUIDelegate {
// Bottleneck through which WebView-based URL opens go
func openInBrowser(_ url: URL, flags: NSEvent.ModifierFlags) {
let invert = flags.contains(.shift) || flags.contains(.command)
Browser.open(url.absoluteString, invertPreference: invert)
}
// WKNavigationDelegate
nonisolated public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if navigationAction.navigationType == .linkActivated {
if let url = navigationAction.request.url {
let flags = navigationAction.modifierFlags
Task { @MainActor in
self.openInBrowser(url, flags: flags)
}
}
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
}
nonisolated public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
Task { @MainActor in
// See note in viewDidLoad()
if waitingForFirstReload {
assert(webView.isHidden)
waitingForFirstReload = false
reloadHTML()
// Waiting for the first navigation to complete isn't long enough to avoid the flash of white.
// A hard coded value is awful, but 5/100th of a second seems to be enough.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
webView.isHidden = false
}
} else {
if let windowScrollY = windowScrollY {
_ = try? await webView.evaluateJavaScript("window.scrollTo(0, \(windowScrollY));")
self.windowScrollY = nil
}
}
}
}
// WKUIDelegate
nonisolated func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
// This method is reached when WebKit handles a JavaScript based window.open() invocation, for example. One
// example where this is used is in YouTube's embedded video player when a user clicks on the video's title
// or on the "Watch in YouTube" button. For our purposes we'll handle such window.open calls the same way we
// handle clicks on a URL.
if let url = navigationAction.request.url {
let flags = navigationAction.modifierFlags
Task { @MainActor in
self.openInBrowser(url, flags: flags)
}
}
return nil
}
}
// MARK: - Private
private extension DetailWebViewController {
func reloadArticleImage() {
guard let article = article else { return }
var components = URLComponents()
components.scheme = ArticleRenderer.imageIconScheme
components.path = article.articleID
if let imageSrc = components.string {
webView?.evaluateJavaScript("reloadArticleImage(\"\(imageSrc)\")")
}
}
func reloadHTMLMaintainingScrollPosition() {
fetchScrollInfo() { scrollInfo in
self.windowScrollY = scrollInfo?.offsetY
self.reloadHTML()
}
}
func reloadHTML() {
delegate?.mouseDidExit(self)
let theme = ArticleThemesManager.shared.currentTheme
let rendering: ArticleRenderer.Rendering
switch state {
case .noSelection:
rendering = ArticleRenderer.noSelectionHTML(theme: theme)
case .multipleSelection:
rendering = ArticleRenderer.multipleSelectionHTML(theme: theme)
case .loading:
rendering = ArticleRenderer.loadingHTML(theme: theme)
case .article(let article, _):
detailIconSchemeHandler.currentArticle = article
rendering = ArticleRenderer.articleHTML(article: article, theme: theme)
case .extracted(let article, let extractedArticle, _):
detailIconSchemeHandler.currentArticle = article
rendering = ArticleRenderer.articleHTML(article: article, extractedArticle: extractedArticle, theme: theme)
}
let substitutions = [
"title": rendering.title,
"baseURL": rendering.baseURL,
"style": rendering.style,
"body": rendering.html
]
let html = try! MacroProcessor.renderedText(withTemplate: ArticleRenderer.page.html, substitutions: substitutions)
// When the app is in /Applications, we want the baseURL to be a local folder in the app bundle. This gives us best performance.
//
// When the app is *not* in /Applications in ~/Applications, for instance  we dont want the baseURL to be a local folder
// because we could up sending referers with a URL like file:///Users/harvey/Applications/NetNewsWire/Contents/Resources
// and obviously we dont want to send peoples names.
// (A URL like file:///Applications/NetNewsWire/Contents/Resources is fine  obviously not exposing a name.)
//
// So, when outside of the /Applications folder, the baseURL is the permalink of the article.
// Which is what wed really want all the time, right? Except that weve had a report of a performance issue
// when we do that all the time, so we prefer the local baseURL when we can.
let localBaseURL = ArticleRenderer.page.baseURL
let articleBaseURL = URL(string: rendering.baseURL)
let baseURL = appIsInApplicationsFolder ? localBaseURL : articleBaseURL
webView.loadHTMLString(html, baseURL: baseURL)
}
func fetchScrollInfo(_ completion: @escaping (ScrollInfo?) -> Void) {
let javascriptString = "var x = {contentHeight: document.body.scrollHeight, offsetY: window.pageYOffset}; x"
webView.evaluateJavaScript(javascriptString) { (info, error) in
guard let info = info as? [String: Any] else {
completion(nil)
return
}
guard let contentHeight = info["contentHeight"] as? CGFloat, let offsetY = info["offsetY"] as? CGFloat else {
completion(nil)
return
}
let scrollInfo = ScrollInfo(contentHeight: contentHeight, viewHeight: self.webView.frame.height, offsetY: offsetY)
completion(scrollInfo)
}
}
#if !MAC_APP_STORE
@objc func webInspectorEnabledDidChange(_ notification: Notification) {
self.webInspectorEnabled = notification.object! as! Bool
}
#endif
}
// MARK: - ScrollInfo
private struct ScrollInfo {
let contentHeight: CGFloat
let viewHeight: CGFloat
let offsetY: CGFloat
let canScrollDown: Bool
let canScrollUp: Bool
init(contentHeight: CGFloat, viewHeight: CGFloat, offsetY: CGFloat) {
self.contentHeight = contentHeight
self.viewHeight = viewHeight
self.offsetY = offsetY
self.canScrollDown = viewHeight + offsetY < contentHeight
self.canScrollUp = offsetY > 0.1
}
}