185 lines
8.3 KiB
Swift
185 lines
8.3 KiB
Swift
import AppKit
|
|
import WebKit
|
|
|
|
// MARK: - ReadModeWebView
|
|
//
|
|
// The WKWebView that backs Read mode. It is a pure renderer of the user's own
|
|
// document: JavaScript is disabled (plus a `script-src 'none'` CSP meta in the
|
|
// page itself), all assets are inlined (so no file/network reach), raw HTML
|
|
// passes through per GFM but filtered by `HTMLRenderer.filterRawHTML`
|
|
// (tagfilter + hardening), and navigation is intercepted — internal scrolling
|
|
// stays, external links open in the default browser, and the view never
|
|
// navigates away from the rendered document (§G, ARCHITECTURE §10).
|
|
//
|
|
// The navigation delegate is a *separate* object (not the webview itself). A
|
|
// WKWebView that is its own `navigationDelegate` does not reliably receive the
|
|
// policy callbacks, so link clicks would navigate in-view instead of opening
|
|
// externally; a dedicated, retained coordinator fixes that.
|
|
@MainActor
|
|
public final class ReadModeWebView: WKWebView {
|
|
|
|
private let coordinator = ReadModeNavigationCoordinator()
|
|
|
|
public init() {
|
|
let config = WKWebViewConfiguration()
|
|
config.defaultWebpagePreferences.allowsContentJavaScript = false
|
|
// QUIRK: `isInspectable` (macOS 13.3+) marks the webview as inspectable
|
|
// but does NOT add the "Inspect Element" context menu on its own. The
|
|
// legacy `developerExtrasEnabled` preference key is what actually shows
|
|
// the menu item. Both must be set for right-click → Inspect Element to
|
|
// work; the developer tools must also be enabled in Safari's settings.
|
|
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
|
|
super.init(frame: .zero, configuration: config)
|
|
coordinator.owner = self
|
|
navigationDelegate = coordinator
|
|
if #available(macOS 13.3, *) { isInspectable = true }
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
/// Called when the user activates a `[[wikilink]]` — the (decoded) target is
|
|
/// routed through the app's document graph rather than navigating the webview.
|
|
public var onOpenWikiLink: ((String) -> Void)?
|
|
|
|
/// Called when the user activates a relative/internal markdown link
|
|
/// destination (e.g. `[text](other.md)`), routed the same way.
|
|
public var onOpenInternalLink: ((String) -> Void)?
|
|
|
|
/// The most recent render inputs, so the view can re-render itself when the
|
|
/// system appearance flips (light ↔ dark) without the document re-driving it.
|
|
private var pending: (markdown: String, theme: EditorTheme,
|
|
callouts: [String: CalloutStyle], baseURL: URL?,
|
|
options: ReadRenderOptions)?
|
|
|
|
/// Renders `markdown` with the given theme; appearance is resolved from the
|
|
/// view itself. `baseURL` is the document's directory (for resolving relative
|
|
/// image paths to inline).
|
|
public func render(markdown: String,
|
|
theme: EditorTheme,
|
|
callouts: [String: CalloutStyle],
|
|
baseURL: URL? = nil,
|
|
options: ReadRenderOptions = .default) {
|
|
pending = (markdown, theme, callouts, baseURL, options)
|
|
reloadHTML()
|
|
}
|
|
|
|
func reloadHTML() {
|
|
guard let p = pending else { return }
|
|
let dark = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
|
|
let html = DocumentHTML.full(markdown: p.markdown, theme: p.theme,
|
|
callouts: p.callouts, dark: dark,
|
|
baseURL: p.baseURL, options: p.options)
|
|
loadHTMLString(html, baseURL: ReadModeNavigationPolicy.trustedBaseURL)
|
|
}
|
|
|
|
public override func viewDidChangeEffectiveAppearance() {
|
|
super.viewDidChangeEffectiveAppearance()
|
|
reloadHTML()
|
|
}
|
|
}
|
|
|
|
// MARK: - Navigation policy
|
|
|
|
/// Intercepts navigation for Read mode: the initial load and in-page anchor
|
|
/// scrolls proceed; any link the user activates opens in the default browser and
|
|
/// the read view stays put.
|
|
@MainActor
|
|
private final class ReadModeNavigationCoordinator: NSObject, WKNavigationDelegate {
|
|
|
|
/// Weak back-reference so the coordinator can re-inject HTML on reload
|
|
/// without needing the webview to be its own delegate.
|
|
weak var owner: ReadModeWebView?
|
|
|
|
// QUIRK: use the *async* form of this delegate method, not the
|
|
// completion-handler form. Under Swift 6 the SDK annotates the
|
|
// completion-handler's closure (`@MainActor @Sendable`); a plain
|
|
// `@escaping (WKNavigationActionPolicy) -> Void` does NOT match the
|
|
// requirement, so the compiler exposes it under the naïve selector
|
|
// `webView:decidePolicyFor:decisionHandler:` instead of the real
|
|
// `webView:decidePolicyForNavigationAction:decisionHandler:`. WebKit's
|
|
// `respondsToSelector:` check then fails and the method is never called —
|
|
// every link navigates in-view. The async form matches the requirement
|
|
// (`webView(_:decidePolicyFor:)`) exactly and registers the correct selector.
|
|
func webView(_ webView: WKWebView,
|
|
decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
|
|
// QUIRK: the page is loaded with an explicit `about:blank` base URL.
|
|
// A user-triggered or WebKit-triggered
|
|
// reload navigates back to `about:blank` and clears the content. Intercept
|
|
// it and re-inject the HTML ourselves instead of allowing the blank reload.
|
|
switch ReadModeNavigationPolicy.decision(for: navigationAction.request.url,
|
|
navigationType: navigationAction.navigationType) {
|
|
case .reload:
|
|
owner?.reloadHTML()
|
|
return .cancel
|
|
case .openWiki(let target):
|
|
owner?.onOpenWikiLink?(target)
|
|
return .cancel
|
|
case .openInternal(let target):
|
|
owner?.onOpenInternalLink?(target)
|
|
return .cancel
|
|
case .openExternal(let url):
|
|
NSWorkspace.shared.open(url)
|
|
return .cancel
|
|
case .allow:
|
|
return .allow
|
|
case .cancel:
|
|
return .cancel
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Navigation classifier
|
|
|
|
enum ReadModeNavigationPolicy {
|
|
|
|
static let trustedBaseURL = URL(string: "about:blank")!
|
|
|
|
enum Decision: Equatable {
|
|
case allow
|
|
case reload
|
|
case openWiki(String)
|
|
case openInternal(String)
|
|
case openExternal(URL)
|
|
case cancel
|
|
}
|
|
|
|
/// Classifies read-mode navigation without touching WebKit/AppKit state. The
|
|
/// generated document is self-contained and loaded against `about:blank`, so
|
|
/// only in-document anchors, Edmund's private schemes, and browser handoffs are
|
|
/// expected. `file:` and other explicit schemes stay out of the webview.
|
|
static func decision(for url: URL?, navigationType: WKNavigationType) -> Decision {
|
|
if navigationType == .reload { return .reload }
|
|
guard let url else { return .allow }
|
|
let scheme = url.scheme?.lowercased()
|
|
|
|
// `[[wikilink]]`s and relative/internal markdown links carry their target
|
|
// in a private scheme (the renderer classifies them). Decode the target
|
|
// and route it through the app's document graph.
|
|
if scheme == HTMLRenderer.wikiScheme {
|
|
return .openWiki(decodeTarget(url, scheme: HTMLRenderer.wikiScheme))
|
|
}
|
|
if scheme == HTMLRenderer.linkScheme {
|
|
return .openInternal(decodeTarget(url, scheme: HTMLRenderer.linkScheme))
|
|
}
|
|
// Decide by URL scheme, not navigation type: WebKit does not reliably
|
|
// report `.linkActivated` for every click. Real web schemes are handed to
|
|
// the user's browser; `about:` covers the initial document and in-page
|
|
// `#fragment` scrolls; anything else is not fetched in the webview.
|
|
if scheme == "http" || scheme == "https" || scheme == "mailto" {
|
|
return .openExternal(url)
|
|
}
|
|
if scheme == nil || scheme == "about" {
|
|
return .allow
|
|
}
|
|
return .cancel
|
|
}
|
|
|
|
/// Recovers the percent-decoded target from a private-scheme URL
|
|
/// (`scheme:encoded`), which has no `//` authority.
|
|
private static func decodeTarget(_ url: URL, scheme: String) -> String {
|
|
let raw = String(url.absoluteString.dropFirst(scheme.count + 1))
|
|
return raw.removingPercentEncoding ?? raw
|
|
}
|
|
}
|