Files
wehub-resource-sync 5b99bf6bca
CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:34:54 +08:00

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
}
}