import SwiftUI
@preconcurrency import WebKit
import UIKit
enum RSSWebAction {
case showPhoto(String)
case searchBook(key: String, scope: String?)
case addBook(String)
case open(name: String, url: String?, title: String?, origin: String?)
case download(url: String, fileName: String)
}
struct RSSWebReaderView: UIViewRepresentable {
let mode: RSSReadingMode
let source: RssSource
let onTextChange: (String) -> Void
let onAction: (RSSWebAction) -> Void
init(mode: RSSReadingMode, source: RssSource, onTextChange: @escaping (String) -> Void = { _ in }, onAction: @escaping (RSSWebAction) -> Void = { _ in }) {
self.mode = mode
self.source = source
self.onTextChange = onTextChange
self.onAction = onAction
}
func makeCoordinator() -> Coordinator { Coordinator(source: source, onTextChange: onTextChange, onAction: onAction) }
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .default()
configuration.allowsInlineMediaPlayback = true
configuration.mediaTypesRequiringUserActionForPlayback = []
configuration.defaultWebpagePreferences.allowsContentJavaScript = source.enableJs
let controller = configuration.userContentController
controller.add(context.coordinator, name: "legado")
controller.addUserScript(.init(source: context.coordinator.bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: false))
if let preload = source.preloadJs?.trimmedNonEmpty {
controller.addUserScript(.init(source: preload, injectionTime: .atDocumentStart, forMainFrameOnly: false))
}
if let inject = source.injectJs?.trimmedNonEmpty {
controller.addUserScript(.init(source: inject, injectionTime: .atDocumentEnd, forMainFrameOnly: false))
}
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.navigationDelegate = context.coordinator
webView.uiDelegate = context.coordinator
webView.allowsBackForwardNavigationGestures = true
webView.scrollView.contentInsetAdjustmentBehavior = .automatic
context.coordinator.installRulesAndLoad(mode: mode, in: webView)
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
context.coordinator.update(source: source)
context.coordinator.onTextChange = onTextChange
context.coordinator.onAction = onAction
context.coordinator.loadIfChanged(mode: mode, in: webView)
}
static func dismantleUIView(_ webView: WKWebView, coordinator: Coordinator) {
webView.stopLoading()
webView.configuration.userContentController.removeScriptMessageHandler(forName: "legado")
webView.navigationDelegate = nil
webView.uiDelegate = nil
}
@MainActor final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler {
private var source: RssSource
var onTextChange: (String) -> Void
var onAction: (RSSWebAction) -> Void
private var signature = ""
private var rulesInstalled = false
private let htmlProcessor = RSSHTMLProcessor()
private var analyzeContent: JSONValue = .string("")
private var analyzeBaseURL = ""
private var analyzeRedirectURL: String?
private var analyzeVariables: [String: String] = [:]
init(source: RssSource, onTextChange: @escaping (String) -> Void, onAction: @escaping (RSSWebAction) -> Void) { self.source = source; self.onTextChange = onTextChange; self.onAction = onAction }
func update(source: RssSource) { self.source = source }
var bridgeScript: String {
let sourceJSON = (try? JSONEncoder().encode(source)).map { String(decoding: $0, as: UTF8.self) } ?? "{}"
return """
(() => {
const native = (action, payload) => window.webkit.messageHandlers.legado.postMessage(Object.assign({action}, payload || {}));
const get = key => window.prompt('__legado_get__', String(key)) || '';
const put = (key, value) => { value = String(value ?? ''); native('put', {key:String(key), value}); return value; };
const ruleCall = payload => {
const result = window.prompt('__legado_rule__', JSON.stringify(payload));
if (result == null || result === '') return null;
try { return JSON.parse(result); } catch (_) { return result; }
};
const analyzer = {
setContent(content, baseUrl) { if (content == null) throw new Error('内容不可空(Content cannot be null)'); ruleCall({op:'setContent', content, baseUrl:baseUrl == null ? null : String(baseUrl)}); return analyzer; },
setBaseUrl(baseUrl) { ruleCall({op:'setBaseUrl', baseUrl:baseUrl == null ? null : String(baseUrl)}); return analyzer; },
setRedirectUrl(url) { return ruleCall({op:'setRedirectUrl', url:String(url)}); },
getStringList(rule, content, isUrl) { return ruleCall({op:'getStringList', rule:rule == null ? null : String(rule), content:content === undefined ? null : content, hasContent:content !== undefined, isUrl:!!isUrl}); },
getString(rule, content, isUrl) { return ruleCall({op:'getString', rule:rule == null ? null : String(rule), content:content === undefined ? null : content, hasContent:content !== undefined && typeof content !== 'boolean', unescape:typeof content === 'boolean' ? content : false, isUrl:!!isUrl}); },
getElement(rule) { return ruleCall({op:'getElement', rule:String(rule)}); },
getElements(rule) { return ruleCall({op:'getElements', rule:String(rule)}); }
};
window.java = window.java || {};
java.get = get; java.put = put;
java.getSource = () => window.source; java.getTag = () => \(Self.jsString(source.sourceName));
java.showPhoto = src => native('showPhoto', {url:String(src)});
java.addBook = url => native('addBook', {url:String(url)});
java.searchBook = (key, scope) => native('searchBook', {key:String(key), scope:scope == null ? null : String(scope)});
java.open = (name, url, title, origin) => native('open', {name:String(name), url:url == null ? null : String(url), title:title == null ? null : String(title), origin:origin == null ? null : String(origin)});
java.setContent = analyzer.setContent; java.setBaseUrl = analyzer.setBaseUrl; java.setRedirectUrl = analyzer.setRedirectUrl;
java.getString = analyzer.getString; java.getStringList = analyzer.getStringList; java.getElement = analyzer.getElement; java.getElements = analyzer.getElements;
window.source = Object.assign(\(sourceJSON), {
getKey: () => \(Self.jsString(source.sourceUrl)),
getTag: () => \(Self.jsString(source.sourceName)),
get, put
});
document.addEventListener('contextmenu', event => {
const image = event.target && event.target.closest ? event.target.closest('img') : null;
if (!image || !image.src) return;
event.preventDefault();
native('showPhoto', {url:String(image.currentSrc || image.src)});
}, true);
})();
"""
}
func installRulesAndLoad(mode: RSSReadingMode, in webView: WKWebView) {
let policy = RSSResourcePolicy(blacklist: source.contentBlacklist, whitelist: source.contentWhitelist)
guard let json = policy.contentBlockerJSON() else {
rulesInstalled = true; loadIfChanged(mode: mode, in: webView); return
}
WKContentRuleListStore.default().compileContentRuleList(forIdentifier: "rss-\(source.sourceUrl.hashValue)", encodedContentRuleList: json) { [weak self, weak webView] list, _ in
DispatchQueue.main.async {
guard let self, let webView else { return }
if let list { webView.configuration.userContentController.add(list) }
self.rulesInstalled = true
self.loadIfChanged(mode: mode, in: webView)
}
}
}
func loadIfChanged(mode: RSSReadingMode, in webView: WKWebView) {
guard rulesInstalled else { return }
let newSignature = String(describing: mode)
guard signature != newSignature else { return }
signature = newSignature
switch mode {
case .html(let content, let baseURL):
webView.loadHTMLString(htmlProcessor.contentHTML(content, style: source.style), baseURL: baseURL.flatMap(URL.init(string:)))
case .startHTML(let content, let baseURL):
webView.loadHTMLString(htmlProcessor.startHTML(content, source: source), baseURL: baseURL.flatMap(URL.init(string:)))
case .web(let value): load(value, in: webView)
case .image(let value):
let escaped = Self.htmlEscape(value)
webView.loadHTMLString(htmlProcessor.contentHTML("
", style: source.style), baseURL: URL(string: value))
case .video(let value):
let escaped = Self.htmlEscape(value)
webView.loadHTMLString(htmlProcessor.contentHTML("", style: source.style), baseURL: URL(string: value))
}
}
private func load(_ value: String, in webView: WKWebView) {
guard let url = URL(string: value) else { return }
var request = URLRequest(url: url, cachePolicy: source.cacheFirst ? .returnCacheDataElseLoad : .useProtocolCachePolicy, timeoutInterval: 180)
if let headers = try? BookSourceHeaderResolver().resolve(source.bookSourceAdapter) {
for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) }
webView.customUserAgent = headers.first { $0.key.caseInsensitiveCompare("User-Agent") == .orderedSame }?.value
}
if webView.customUserAgent == nil { webView.customUserAgent = ApplicationSettingsStore.shared.settings.effectiveUserAgent }
request = ApplicationSettingsStore.shared.settings.configure(request)
let cookie = LegadoCookieStore.shared.cookieHeader(for: value)
if !cookie.isEmpty { request.setValue(cookie, forHTTPHeaderField: "Cookie") }
webView.load(request)
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard let body = message.body as? [String: Any], let action = body["action"] as? String else { return }
switch action {
case "put":
if let key = body["key"] as? String { BookSourceRuntimeStore.shared.put(sourceID: source.sourceUrl, key: key, value: String(describing: body["value"] ?? "")) }
case "showPhoto":
if let raw = body["url"] as? String { DispatchQueue.main.async { self.onAction(.showPhoto(raw)) } }
case "searchBook":
if let key = body["key"] as? String { DispatchQueue.main.async { self.onAction(.searchBook(key: key, scope: body["scope"] as? String)) } }
case "addBook":
if let raw = body["url"] as? String { DispatchQueue.main.async { self.onAction(.addBook(raw)) } }
case "open":
let name = body["name"] as? String ?? ""
DispatchQueue.main.async { self.onAction(.open(name: name, url: body["url"] as? String, title: body["title"] as? String, origin: body["origin"] as? String)) }
default: break
}
}
func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping @MainActor @Sendable (String?) -> Void) {
if prompt == "__legado_get__" {
completionHandler(BookSourceRuntimeStore.shared.get(sourceID: source.sourceUrl, key: defaultText ?? ""))
} else if prompt == "__legado_rule__" {
handleRulePrompt(defaultText, completionHandler: completionHandler)
} else { completionHandler(defaultText) }
}
private func handleRulePrompt(_ payload: String?, completionHandler: @escaping @MainActor @Sendable (String?) -> Void) {
guard let payload, let data = payload.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let operation = object["op"] as? String else { completionHandler("null"); return }
switch operation {
case "setContent":
analyzeContent = JSONValue.bridge(object["content"])
if let base = object["baseUrl"] as? String, !base.isEmpty { analyzeBaseURL = base }
completionHandler("true")
case "setBaseUrl":
if let base = object["baseUrl"] as? String, !base.isEmpty { analyzeBaseURL = base }
completionHandler("true")
case "setRedirectUrl":
let value = object["url"] as? String ?? ""
if !value.lowercased().hasPrefix("data:"), URL(string: value) != nil { analyzeRedirectURL = value }
completionHandler(Self.jsonString(analyzeRedirectURL))
default:
let rule = object["rule"] as? String ?? ""
guard !rule.isEmpty else { completionHandler(operation == "getStringList" || operation == "getElements" ? "null" : "\"\""); return }
let input = (object["hasContent"] as? Bool) == true ? JSONValue.bridge(object["content"]) : analyzeContent
let baseURL = analyzeRedirectURL ?? analyzeBaseURL
let context = RuleExecutionContext(variables: analyzeVariables, baseURL: baseURL, source: source.bookSourceAdapter)
Task {
let evaluation: RuleEvaluation
switch input {
case .string(let text):
evaluation = await RuleEngine().evaluate(rule, inText: text, baseURL: baseURL, listMode: operation == "getStringList" || operation == "getElements", context: context)
default:
evaluation = await RuleEngine().evaluate(rule, in: input, context: context)
}
self.analyzeVariables = evaluation.variables
let isURL = object["isUrl"] as? Bool == true
let values = evaluation.values.map { value -> JSONValue in
guard isURL else { return value }
let raw = value.stringValue
return .string(URL(string: raw, relativeTo: URL(string: baseURL))?.absoluteURL.absoluteString ?? raw)
}
let response: Any
switch operation {
case "getStringList": response = values.map(\.stringValue)
case "getElement": response = values.first?.foundationObject ?? NSNull()
case "getElements": response = values.map(\.foundationObject)
default:
var value = values.map(\.stringValue).joined(separator: "\n")
if object["unescape"] as? Bool == true { value = Self.unescapeHTML(value) }
response = value
}
completionHandler(Self.json(response))
}
}
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) {
guard let url = navigationAction.request.url else { decisionHandler(.cancel); return }
let scheme = url.scheme?.lowercased() ?? ""
if !["http", "https", "file", "data", "about"].contains(scheme) {
UIApplication.shared.open(url); decisionHandler(.cancel); return
}
if navigationAction.navigationType == .linkActivated, shouldOverride(url.absoluteString) {
decisionHandler(.cancel); return
}
decisionHandler(.allow)
}
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping @MainActor @Sendable (WKNavigationResponsePolicy) -> Void) {
guard let response = navigationResponse.response as? HTTPURLResponse, let url = response.url else { decisionHandler(.allow); return }
let disposition = response.value(forHTTPHeaderField: "Content-Disposition")?.lowercased() ?? ""
if disposition.contains("attachment") || !navigationResponse.canShowMIMEType {
let name = response.suggestedFilename ?? url.lastPathComponent.nilIfBlank ?? "download"
DispatchQueue.main.async { self.onAction(.download(url: url.absoluteString, fileName: name)) }
decisionHandler(.cancel)
} else { decisionHandler(.allow) }
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
webView.evaluateJavaScript("document.body ? document.body.innerText : ''") { [weak self] value, _ in
guard let text = value as? String else { return }
DispatchQueue.main.async { self?.onTextChange(text) }
}
}
private func shouldOverride(_ url: String) -> Bool {
guard let script = source.shouldOverrideUrlLoading?.trimmedNonEmpty else { return false }
let quoted = Self.jsString(url)
let expression = "(function(url){return (\(script));})(\(quoted))"
let body = "(function(url){\(script)})(\(quoted))"
let evaluator = JavaScriptRuleEvaluator()
let context = RuleExecutionContext(variables: ["url": url], baseURL: url, source: source.bookSourceAdapter)
let result = (try? evaluator.evaluate(script: expression, result: .string(url), executionContext: context))
?? (try? evaluator.evaluate(script: body, result: .string(url), executionContext: context))
let value = result?.value.stringValue.lowercased() ?? ""
return ["true", "1", "yes"].contains(value)
}
private static func jsString(_ value: String) -> String {
let data = try? JSONEncoder().encode(value)
return data.map { String(decoding: $0, as: UTF8.self) } ?? "\"\""
}
private static func htmlEscape(_ value: String) -> String {
value.replacingOccurrences(of: "&", with: "&").replacingOccurrences(of: "\"", with: """).replacingOccurrences(of: "<", with: "<")
}
private static func json(_ value: Any) -> String {
guard JSONSerialization.isValidJSONObject([value]), let data = try? JSONSerialization.data(withJSONObject: value, options: .fragmentsAllowed) else { return "null" }
return String(decoding: data, as: UTF8.self)
}
private static func jsonString(_ value: String?) -> String { value.map { json($0) } ?? "null" }
private static func unescapeHTML(_ value: String) -> String {
guard let data = "\(value)".data(using: .utf8), let attributed = try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue], documentAttributes: nil) else { return value }
return attributed.string
}
}
}
private extension String {
var trimmedNonEmpty: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
var nilIfBlank: String? { trimmedNonEmpty }
}