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