mirror of
https://github.com/Ranchero-Software/NetNewsWire.git
synced 2024-12-24 16:51:19 +01:00
428 lines
14 KiB
Swift
428 lines
14 KiB
Swift
//
|
||
// 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 don’t 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 don’t want to send people’s 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 we’d really want all the time, right? Except that we’ve 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
|
||
}
|
||
}
|