import CryptoKit import CommonCrypto import Foundation import JavaScriptCore public struct RuleExecutionContext: Sendable { public var variables: [String: String] public var baseURL: String public var key: String? public var page: Int? public var source: BookSource? public var book: Book? public var chapter: BookChapter? public var isHTMLFragment: Bool public var content: JSONValue? public init( variables: [String: String] = [:], baseURL: String = "", key: String? = nil, page: Int? = nil, source: BookSource? = nil, book: Book? = nil, chapter: BookChapter? = nil, isHTMLFragment: Bool = false, content: JSONValue? = nil ) { self.variables = variables self.baseURL = baseURL self.key = key self.page = page self.source = source self.book = book self.chapter = chapter self.isHTMLFragment = isHTMLFragment self.content = content } } public struct JavaScriptRuleResult: Sendable { public var value: JSONValue public var variables: [String: String] } /// JavaScript's synchronous network bridges can block. Keep those calls off /// Swift's cooperative executor and off shared actors such as the cookie policy. enum RuleExecutionWorker { static func run(_ operation: @escaping @Sendable () throws -> Value) async throws -> Value { try Task.checkCancellation() let result = try await withCheckedThrowingContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { continuation.resume(with: Result { try operation() }) } } try Task.checkCancellation() return result } } public protocol JavaScriptNetworkLoading: Sendable { func load(_ request: URLRequest) throws -> HTTPResponse } public protocol JavaScriptRedirectNetworkLoading: JavaScriptNetworkLoading { func load(_ request: URLRequest, followRedirects: Bool) throws -> HTTPResponse } public final class SynchronousJavaScriptNetworkLoader: JavaScriptRedirectNetworkLoading, @unchecked Sendable { public init() {} public func load(_ request: URLRequest) throws -> HTTPResponse { try load(request, followRedirects: true) } public func load(_ original: URLRequest, followRedirects: Bool) throws -> HTTPResponse { let settings = ApplicationSettingsStore.shared.settings let request = settings.configure(original) let address = try ApplicationSettings.parseHosts(settings.hosts)[request.url?.host?.lowercased() ?? ""] if followRedirects || (settings.proxy.isEmpty && address != nil) { let semaphore = DispatchSemaphore(value: 0) let lock = NSLock() nonisolated(unsafe) var result: Result? // The synchronous JS caller waits for this request. Detached tasks // otherwise default to medium priority and cause priority inversion // during interactive search/source switching. let task = Task.detached(priority: Task.currentPriority) { let value: Result do { let response: HTTPResponse if followRedirects { response = try await URLSessionHTTPTransport.shared.data(for: request) } else { response = try await HostsHTTPTransport.data(for: request, address: address!, ignoreCertificateErrors: settings.ignoreCertificateErrors) } value = .success(response) } catch HTTPClientError.statusResponse(let response) { value = .success(response) } catch { value = .failure(error) } lock.withLock { result = value } semaphore.signal() } guard semaphore.wait(timeout: .now() + max(1, request.timeoutInterval) + 1) == .success else { task.cancel() throw URLError(.timedOut) } lock.lock(); let value = result; lock.unlock() return try value?.get() ?? { throw HTTPClientError.nonHTTPResponse }() } let configuration = URLSessionConfiguration.ephemeral configuration.httpCookieAcceptPolicy = .always configuration.httpShouldSetCookies = true configuration.httpCookieStorage = .shared try settings.configure(configuration) let delegate: any URLSessionDelegate = followRedirects ? LegadoUnsafeTrustDelegate.shared : JavaScriptRedirectBlocker() let callbackQueue = OperationQueue() callbackQueue.qualityOfService = Thread.current.qualityOfService let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: callbackQueue) defer { session.finishTasksAndInvalidate() } let started = Date() do { let response = try load(request, session: session) DiagnosticLog.shared.network(request, status: response.statusCode, startedAt: started) return response } catch { DiagnosticLog.shared.network(request, error: error, startedAt: started) throw error } } private func load(_ request: URLRequest, session: URLSession) throws -> HTTPResponse { let semaphore = DispatchSemaphore(value: 0) let lock = NSLock() nonisolated(unsafe) var output: Result? let task = session.dataTask(with: request) { data, response, error in let result: Result if let error { result = .failure(error) } else if let http = response as? HTTPURLResponse, let url = http.url { result = .success(.init( data: data ?? Data(), statusCode: http.statusCode, headers: http.allHeaderFields.reduce(into: [:]) { values, entry in values[String(describing: entry.key)] = String(describing: entry.value) }, finalURL: url )) } else { result = .failure(HTTPClientError.nonHTTPResponse) } lock.lock() output = result lock.unlock() semaphore.signal() } task.resume() let timeout = min(max(request.timeoutInterval, 1), 180) guard semaphore.wait(timeout: .now() + timeout + 1) == .success else { task.cancel() throw URLError(.timedOut) } lock.lock() let result = output lock.unlock() return try result?.get() ?? { throw HTTPClientError.nonHTTPResponse }() } } private final class JavaScriptRedirectBlocker: NSObject, URLSessionTaskDelegate, @unchecked Sendable { func urlSession( _ session: URLSession, didReceive challenge: URLAuthenticationChallenge ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { LegadoUnsafeTrustDelegate.resolution(for: challenge) } func urlSession( _ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void ) { completionHandler(nil) } } public struct JavaScriptRuleEvaluator: Sendable { public var maximumScriptLength: Int public var maximumResultLength: Int private let networkLoader: any JavaScriptNetworkLoading private let urlBuilder: LegadoURLBuilder public func evaluateAsync(script: String, result: JSONValue, executionContext: RuleExecutionContext) async throws -> JavaScriptRuleResult { try await RuleExecutionWorker.run { try evaluate(script: script, result: result, executionContext: executionContext) } } public init( maximumScriptLength: Int = 64 * 1_024, maximumResultLength: Int = 2 * 1_024 * 1_024, networkLoader: any JavaScriptNetworkLoading = SynchronousJavaScriptNetworkLoader(), urlBuilder: LegadoURLBuilder = .init() ) { self.maximumScriptLength = maximumScriptLength self.maximumResultLength = maximumResultLength self.networkLoader = networkLoader self.urlBuilder = urlBuilder } public func evaluate( script: String, result: JSONValue, executionContext: RuleExecutionContext ) throws -> JavaScriptRuleResult { guard script.utf8.count <= maximumScriptLength else { throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 规则超过长度上限") } try rejectUnboundedExecution(script) let box = JSVariableBox(executionContext.variables) let vm = JSVirtualMachine() guard let context = JSContext(virtualMachine: vm) else { throw RuleEngineError.invalidInput(rule: script, expected: "无法创建 JavaScriptCore 上下文") } var exceptionMessage: String? context.exceptionHandler = { _, exception in exceptionMessage = exception?.toString() ?? "未知 JavaScript 异常" DiagnosticLog.shared.record(.error, category: "JS", message: exceptionMessage ?? "未知 JavaScript 异常") } context.setObject(result.foundationObject, forKeyedSubscript: "result" as NSString) context.setObject((executionContext.content ?? result).foundationObject, forKeyedSubscript: "src" as NSString) context.setObject(result.foundationObject, forKeyedSubscript: "$" as NSString) context.setObject(executionContext.baseURL, forKeyedSubscript: "baseUrl" as NSString) context.setObject(executionContext.key ?? "", forKeyedSubscript: "key" as NSString) context.setObject(executionContext.page ?? 0, forKeyedSubscript: "page" as NSString) installJava(in: context, variables: box, result: result, executionContext: executionContext) installCookie(in: context) installJavaImporterCompatibility(in: context) installSource(in: context, source: executionContext.source, variables: box) installBook(in: context, book: executionContext.book) installChapter(in: context, chapter: executionContext.chapter) context.evaluateScript(""" (function () { var nativeParse = JSON.parse; JSON.parse = function (value) { if (typeof value !== 'string') return value; var text = value; if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1); text = text.trim(); if (!text || '[{"-0123456789tfn'.indexOf(text.charAt(0)) < 0) return value; return nativeParse(text); }; })(); """) if let library = executionContext.source?.jsLib?.trimmingCharacters(in: .whitespacesAndNewlines), !library.isEmpty { guard library.utf8.count <= maximumScriptLength else { throw RuleEngineError.resourceLimit(rule: library, detail: "书源 jsLib 超过长度上限") } try rejectUnboundedExecution(library) context.evaluateScript(library) if let exceptionMessage { throw RuleEngineError.malformedRule(rule: library, detail: "书源 jsLib 执行失败:\(exceptionMessage)") } } let evaluated = context.evaluateScript(script) if let exceptionMessage { throw RuleEngineError.malformedRule(rule: script, detail: "JavaScript 执行失败:\(exceptionMessage)") } // JavaString supplies getBytes() on a boxed JS String. JSC otherwise // bridges it as {"0":"第", "1":"一", ...}, corrupting decrypted text. let normalized = evaluated.flatMap { context.objectForKeyedSubscript("__legadoUnboxString")?.call(withArguments: [$0]) } let value = JSONValue.bridge(normalized?.toObject()) guard value.stringValue.utf8.count <= maximumResultLength else { throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 结果超过长度上限") } return .init(value: value, variables: box.values) } private func installJava( in context: JSContext, variables: AnyObject, result: JSONValue, executionContext: RuleExecutionContext ) { guard let box = variables as? VariableBoxProxy else { return } installJavaBlocks( in: context, get: { key in if key == "bookName", let book = executionContext.book { return book.name } if key == "title", let chapter = executionContext.chapter { return chapter.title } return box.get(key) }, put: { box.put($0, $1) }, result: result, executionContext: executionContext ) } private func installCookie(in context: JSContext) { let object = JSValue(newObjectIn: context) let getCookie: @convention(block) (String) -> String = { LegadoCookieStore.shared.cookieHeader(for: $0) } let getKey: @convention(block) (String, String) -> String = { LegadoCookieStore.shared.cookie(for: $0, key: $1) } let setCookie: @convention(block) (String, String?) -> Void = { url, value in LegadoCookieStore.shared.setCookie(value ?? "", for: url) } let removeCookie: @convention(block) (String) -> Void = { LegadoCookieStore.shared.removeCookies(for: $0) } object?.setObject(getCookie, forKeyedSubscript: "getCookie" as NSString) object?.setObject(getKey, forKeyedSubscript: "getKey" as NSString) object?.setObject(setCookie, forKeyedSubscript: "setCookie" as NSString) object?.setObject(setCookie, forKeyedSubscript: "replaceCookie" as NSString) object?.setObject(setCookie, forKeyedSubscript: "setWebCookie" as NSString) object?.setObject(removeCookie, forKeyedSubscript: "removeCookie" as NSString) context.setObject(object, forKeyedSubscript: "cookie" as NSString) context.evaluateScript(""" (function () { cookie.cookieToMap = function (header) { var map = {}; String(header || '').split(';').forEach(function (part) { var separator = part.indexOf('='); if (separator <= 0) return; var key = part.slice(0, separator).trim(); var value = part.slice(separator + 1).trim(); if (value || value === 'null') map[key] = value; }); return map; }; cookie.mapToCookie = function (map) { if (!map) return null; var values = []; for (var key in map) { if (Object.prototype.hasOwnProperty.call(map, key)) { values.push(key + '=' + map[key]); } } return values.length ? values.join('; ') : null; }; })(); """) } private func installJavaImporterCompatibility(in context: JSContext) { let utf8Bytes: @convention(block) (String) -> [Int] = { value in Data(value.utf8).map { Int(Int8(bitPattern: $0)) } } let utf8String: @convention(block) (Any?) -> String = { value in String(decoding: Self.byteData(from: value), as: UTF8.self) } let maximumBytes = maximumResultLength let aesCBC: @convention(block) (Int, Any?, Any?, Any?) -> [Int]? = { operation, keyValue, ivValue, dataValue in let key = Self.byteData(from: keyValue) let iv = Self.byteData(from: ivValue) let input = Self.byteData(from: dataValue) guard [1, 2].contains(operation), [16, 24, 32].contains(key.count), iv.count == kCCBlockSizeAES128, input.count <= maximumBytes else { return nil } var output = [UInt8](repeating: 0, count: input.count + kCCBlockSizeAES128) let capacity = output.count var written = 0 let status = key.withUnsafeBytes { keyBytes in iv.withUnsafeBytes { ivBytes in input.withUnsafeBytes { inputBytes in CCCrypt(operation == 1 ? CCOperation(kCCEncrypt) : CCOperation(kCCDecrypt), CCAlgorithm(kCCAlgorithmAES), CCOptions(kCCOptionPKCS7Padding), keyBytes.baseAddress, key.count, ivBytes.baseAddress, inputBytes.baseAddress, input.count, &output, capacity, &written) } } } guard status == kCCSuccess else { return nil } return output.prefix(written).map { Int(Int8(bitPattern: $0)) } } let base64EncodeBytes: @convention(block) (Any?) -> String = { value in Self.byteData(from: value).base64EncodedString() } let base64DecodeBytes: @convention(block) (String) -> [Int] = { value in guard let data = Data(base64Encoded: value) else { return [] } return data.map { Int(Int8(bitPattern: $0)) } } let hexEncodeBytes: @convention(block) (Any?) -> String = { value in Self.byteData(from: value).map { String(format: "%02X", $0) }.joined() } let hmacSHA1: @convention(block) (Any?, Any?) -> [Int] = { keyValue, dataValue in let key = SymmetricKey(data: Self.byteData(from: keyValue)) let authentication = HMAC.authenticationCode( for: Self.byteData(from: dataValue), using: key ) return authentication.map { Int(Int8(bitPattern: $0)) } } let formURLEncode: @convention(block) (String) -> String = { Self.formURLEncode($0) } context.setObject(utf8Bytes, forKeyedSubscript: "__legadoUTF8Bytes" as NSString) context.setObject(utf8String, forKeyedSubscript: "__legadoUTF8String" as NSString) context.setObject(aesCBC, forKeyedSubscript: "__legadoAESCBC" as NSString) context.setObject(base64EncodeBytes, forKeyedSubscript: "__legadoBase64EncodeBytes" as NSString) context.setObject(base64DecodeBytes, forKeyedSubscript: "__legadoBase64DecodeBytes" as NSString) context.setObject(hexEncodeBytes, forKeyedSubscript: "__legadoHexEncodeBytes" as NSString) context.setObject(hmacSHA1, forKeyedSubscript: "__legadoHmacSHA1" as NSString) context.setObject(formURLEncode, forKeyedSubscript: "__legadoFormURLEncode" as NSString) context.evaluateScript(Self.javaImporterBootstrap) } private func installJavaBlocks( in context: JSContext, get: @escaping (String) -> String, put: @escaping (String, String) -> String, result: JSONValue, executionContext: RuleExecutionContext ) { let java = JSValue(newObjectIn: context) let getBlock: @convention(block) (String) -> String = { key in get(key) } let putBlock: @convention(block) (String, Any?) -> String = { key, value in put(key, Self.stringify(value)) } let putVolumeBlock: @convention(block) (Any?) -> String = { value in let title = Self.stringify(value) var volumes = (try? JSONDecoder().decode([String].self, from: Data(get("__legadoVolumes").utf8))) ?? [] volumes.append(title) if let data = try? JSONEncoder().encode(volumes) { _ = put("__legadoVolumes", String(decoding: data, as: UTF8.self)) } return title } let getStringBlock: @convention(block) (String, Any?, Bool) -> String = { rule, content, isURL in let input = content.map(JSONValue.bridge) ?? executionContext.content ?? result return (try? self.evaluateStringRule( rule, content: input, isURL: isURL, executionContext: executionContext )) ?? "" } let md5Block: @convention(block) (String) -> String = { value in Insecure.MD5.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() } let base64Encode: @convention(block) (String) -> String = { Data($0.utf8).base64EncodedString() } let base64Decode: @convention(block) (String) -> String = { Data(base64Encoded: $0).map { String(decoding: $0, as: UTF8.self) } ?? "" } let base64DecodeToByteArray: @convention(block) (String) -> [Int] = { guard let data = Data(base64Encoded: $0) else { return [] } return data.map { Int(Int8(bitPattern: $0)) } } let strToBytes: @convention(block) (String) -> [Int] = { Array($0.utf8).map { Int(Int8(bitPattern: $0)) } } let bytesToStr: @convention(block) (Any?) -> String = { value in guard let values = value as? [Any] else { return "" } let bytes = values.compactMap { item -> UInt8? in if let number = item as? NSNumber { return UInt8(truncatingIfNeeded: number.intValue) } if let number = Int(Self.stringify(item)) { return UInt8(truncatingIfNeeded: number) } return nil } return String(decoding: bytes, as: UTF8.self) } let hexDecodeToByteArray: @convention(block) (String) -> [Int] = { value in Self.hexBytes(value).map { Int(Int8(bitPattern: $0)) } } let hexDecodeToString: @convention(block) (String) -> String = { value in String(decoding: Self.hexBytes(value), as: UTF8.self) } let hexEncodeToString: @convention(block) (String) -> String = { value in Data(value.utf8).map { String(format: "%02x", $0) }.joined() } let timeFormatUTC: @convention(block) (Double, String, Int) -> String = { milliseconds, format, offset in let formatter = DateFormatter() formatter.locale = Locale.current formatter.timeZone = TimeZone(secondsFromGMT: offset / 1_000) formatter.dateFormat = format return formatter.string(from: Date(timeIntervalSince1970: milliseconds / 1_000)) } let timeFormat: @convention(block) (Double) -> String = { milliseconds in let formatter = DateFormatter() formatter.locale = Locale.current formatter.dateFormat = "yyyy/MM/dd HH:mm" return formatter.string(from: Date(timeIntervalSince1970: milliseconds / 1_000)) } let randomUUID: @convention(block) () -> String = { UUID().uuidString } let deviceID: @convention(block) () -> String = { let key = "ios.installation.identifier" let stored = BookSourceRuntimeStore.shared.get(sourceID: "__app__", key: key) if !stored.isEmpty { return stored } let value = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() BookSourceRuntimeStore.shared.put(sourceID: "__app__", key: key, value: value) return value } let getTag: @convention(block) () -> String = { executionContext.source?.id ?? "" } let logValue: @convention(block) (Any?) -> Any? = { value in DiagnosticLog.shared.record(category: "JS", message: String(describing: value ?? "null")) return value } context.setObject(logValue, forKeyedSubscript: "__legadoLog" as NSString) context.evaluateScript("var console = { log: function() { __legadoLog(Array.prototype.join.call(arguments, ' ')); }, warn: function() { __legadoLog(Array.prototype.join.call(arguments, ' ')); }, error: function() { __legadoLog(Array.prototype.join.call(arguments, ' ')); } };") let urlEncode: @convention(block) (String) -> String = { Self.formURLEncode($0) } let getCookieHeader: @convention(block) (String) -> String = { LegadoCookieStore.shared.cookieHeader(for: $0) } let getCookieValue: @convention(block) (String, String) -> String = { LegadoCookieStore.shared.cookie(for: $0, key: $1) } let setCookie: @convention(block) (String, String) -> Void = { LegadoCookieStore.shared.setCookie($1, for: $0) } let ajax: @convention(block) (Any?, Any?) -> String = { value, timeoutValue in let rawURL = Self.firstURL(value) do { let response = try self.loadJavaScriptRequest( rawURL, extraHeaders: [:], timeoutValue: timeoutValue, executionContext: executionContext ) return response.decodedText } catch { return error.localizedDescription } } let ajaxAll: @convention(block) (Any?, Any?) -> [[String: Any]] = { value, _ in let urls: [String] if let values = value as? [Any] { urls = values.map(Self.stringify) } else { urls = [Self.stringify(value)] } return urls.map { rawURL in do { let response = try self.loadJavaScriptRequest( rawURL, extraHeaders: [:], timeoutValue: nil, executionContext: executionContext ) return [ "url": response.finalURL.absoluteString, "body": response.decodedText, "code": response.statusCode, "headers": response.headers ] } catch { return [ "url": rawURL, "body": error.localizedDescription, "code": 0, "headers": [:] ] } } } let directRequest: @convention(block) (String, String, Any?, Any?, Any?) -> [String: Any] = { method, rawURL, body, headers, timeout in do { let response = try self.loadDirectJavaScriptRequest( method: method, rawURL: rawURL, body: body.map { Self.stringify($0) }, headers: Self.headerMap(headers), timeoutValue: timeout, executionContext: executionContext ) return [ "urlText": response.finalURL.absoluteString, "bodyText": response.decodedText, "code": response.statusCode, "headers": response.headers ] } catch { return ["error": error.localizedDescription] } } let connect: @convention(block) (Any?, Any?, Any?) -> [String: Any] = { value, headerValue, timeoutValue in let rawURL = Self.firstURL(value) do { let response = try self.loadJavaScriptRequest( rawURL, extraHeaders: Self.headerMap(headerValue), timeoutValue: timeoutValue, executionContext: executionContext ) return [ "url": response.finalURL.absoluteString, "body": response.decodedText, "code": response.statusCode, "message": HTTPURLResponse.localizedString(forStatusCode: response.statusCode), "headers": response.headers, "successful": (200 ... 299).contains(response.statusCode), "callTime": 0 ] } catch { return [ "url": rawURL, "body": error.localizedDescription, "code": 0, "message": error.localizedDescription, "headers": [:], "successful": false, "callTime": 0 ] } } java?.setObject(getBlock, forKeyedSubscript: "get" as NSString) java?.setObject(putBlock, forKeyedSubscript: "put" as NSString) java?.setObject(putVolumeBlock, forKeyedSubscript: "putVolume" as NSString) java?.setObject(getStringBlock, forKeyedSubscript: "__getString" as NSString) java?.setObject(md5Block, forKeyedSubscript: "md5Encode" as NSString) java?.setObject(base64Encode, forKeyedSubscript: "base64Encode" as NSString) java?.setObject(base64Decode, forKeyedSubscript: "base64Decode" as NSString) java?.setObject(base64DecodeToByteArray, forKeyedSubscript: "base64DecodeToByteArray" as NSString) java?.setObject(strToBytes, forKeyedSubscript: "strToBytes" as NSString) java?.setObject(bytesToStr, forKeyedSubscript: "bytesToStr" as NSString) java?.setObject(hexDecodeToByteArray, forKeyedSubscript: "hexDecodeToByteArray" as NSString) java?.setObject(hexDecodeToString, forKeyedSubscript: "hexDecodeToString" as NSString) java?.setObject(hexEncodeToString, forKeyedSubscript: "hexEncodeToString" as NSString) java?.setObject(timeFormatUTC, forKeyedSubscript: "timeFormatUTC" as NSString) java?.setObject(timeFormat, forKeyedSubscript: "timeFormat" as NSString) java?.setObject(randomUUID, forKeyedSubscript: "randomUUID" as NSString) java?.setObject(deviceID, forKeyedSubscript: "androidId" as NSString) java?.setObject(getTag, forKeyedSubscript: "getTag" as NSString) java?.setObject(logValue, forKeyedSubscript: "log" as NSString) java?.setObject(logValue, forKeyedSubscript: "logType" as NSString) java?.setObject(logValue, forKeyedSubscript: "toast" as NSString) java?.setObject(urlEncode, forKeyedSubscript: "encodeURI" as NSString) java?.setObject(getCookieHeader, forKeyedSubscript: "getCookieHeader" as NSString) java?.setObject(getCookieValue, forKeyedSubscript: "__getCookieValue" as NSString) java?.setObject(setCookie, forKeyedSubscript: "setCookie" as NSString) java?.setObject(ajax, forKeyedSubscript: "ajax" as NSString) java?.setObject(ajaxAll, forKeyedSubscript: "ajaxAll" as NSString) java?.setObject(connect, forKeyedSubscript: "connect" as NSString) java?.setObject(directRequest, forKeyedSubscript: "__directRequest" as NSString) context.setObject(java, forKeyedSubscript: "java" as NSString) context.evaluateScript(""" (function () { var getVariable = java.get; function responseOf(raw) { if (raw.error) throw new Error(raw.error); return { statusCode: function () { return raw.code; }, body: function () { return raw.bodyText; }, header: function (name) { var wanted = String(name).toLowerCase(); for (var key in raw.headers) if (key.toLowerCase() === wanted) return raw.headers[key]; return null; }, headers: function () { return raw.headers; }, url: function () { return { toString: function () { return raw.urlText; } }; } }; } java.get = function (url, headers, timeout) { var target = String(url); var networkURL = /^(https?:|data:)/i.test(target) || target[0] === '/' || target.indexOf('./') === 0 || target.indexOf('../') === 0; if (arguments.length === 1 && !networkURL) { return getVariable(target); } return responseOf(java.__directRequest('GET', target, null, headers || {}, timeout)); }; java.getString = function (rule, content, isUrl) { return java.__getString( String(rule == null ? '' : rule), arguments.length < 2 || content == null ? src : content, Boolean(isUrl) ); }; java.head = function (url, headers, timeout) { return responseOf(java.__directRequest('HEAD', String(url), null, headers || {}, timeout)); }; java.post = function (url, body, headers, timeout) { return responseOf(java.__directRequest('POST', String(url), body, headers || {}, timeout)); }; java.getCookie = function (tag, key) { return arguments.length < 2 || key == null ? java.getCookieHeader(String(tag)) : java.__getCookieValue(String(tag), String(key)); }; java.getSource = function () { return typeof source === 'undefined' ? null : source; }; java.getWebViewUA = function () { return '\(LegadoURLBuilder.defaultUserAgent)'; }; java.getHeaderMap = function () { var map = typeof source === 'undefined' ? {} : source.getHeaderMap(true); map.put = function (key, value) { map[String(key)] = String(value); return value; }; map.putAll = function (other) { for (var key in other) if (typeof other[key] !== 'function') map[key] = other[key]; }; return map; }; java.ajaxTestAll = function (urls, timeout) { return java.ajaxAll(urls, timeout).map(function (raw) { var bodyText = raw.body; raw.callTime = function () { return raw.code >= 200 && raw.code < 300 ? 0 : -1; }; raw.body = function () { return bodyText; }; return raw; }); }; java.clearTtsCache = function () { return true; }; })(); """) } private func loadDirectJavaScriptRequest( method: String, rawURL: String, body: String?, headers: [String: String], timeoutValue: Any?, executionContext: RuleExecutionContext ) throws -> HTTPResponse { let baseText = executionContext.baseURL.isEmpty ? executionContext.source?.bookSourceUrl ?? "" : executionContext.baseURL let baseURL = URL(string: baseText) guard let url = URL(string: rawURL, relativeTo: baseURL)?.absoluteURL, let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme) else { throw RuleEngineError.invalidInput(rule: rawURL, expected: "java.get/head/post URL") } let milliseconds = Self.number(timeoutValue) ?? 30_000 var request = URLRequest(url: url, timeoutInterval: min(max(milliseconds / 1_000, 1), 180)) request.httpMethod = method.uppercased() request.httpShouldHandleCookies = executionContext.source?.enabledCookieJar == true var requestHeaders = try executionContext.source.map { try BookSourceHeaderResolver().resolve($0) } ?? [:] requestHeaders.merge(headers) { _, explicit in explicit } if !requestHeaders.keys.contains(where: { $0.caseInsensitiveCompare("User-Agent") == .orderedSame }) { requestHeaders["User-Agent"] = ApplicationSettingsStore.shared.settings.effectiveUserAgent } requestHeaders.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) } if method.caseInsensitiveCompare("POST") == .orderedSame { request.httpBody = Data((body ?? "").utf8) } if executionContext.source?.enabledCookieJar == true { let stored = LegadoCookieStore.shared.cookieHeader(for: rawURL) let explicit = request.value(forHTTPHeaderField: "Cookie") ?? "" let merged = Self.mergeCookieHeaders(stored, explicit) if !merged.isEmpty { request.setValue(merged, forHTTPHeaderField: "Cookie") } } let response: HTTPResponse if let redirectLoader = networkLoader as? any JavaScriptRedirectNetworkLoading { response = try redirectLoader.load(request, followRedirects: false) } else { response = try networkLoader.load(request) } if let cookie = response.headers.first(where: { $0.key.caseInsensitiveCompare("Set-Cookie") == .orderedSame })?.value { LegadoCookieStore.shared.setCookie(cookie, for: response.finalURL.absoluteString) } return response } private func loadJavaScriptRequest( _ rawURL: String, extraHeaders: [String: String], timeoutValue: Any?, executionContext: RuleExecutionContext ) throws -> HTTPResponse { guard !rawURL.isEmpty else { throw RuleEngineError.invalidInput(rule: rawURL, expected: "java.ajax/connect URL") } let source = executionContext.source var headers = try source.map { try BookSourceHeaderResolver().resolve($0) } ?? [:] headers.merge(extraHeaders) { _, extra in extra } let base = URL(string: executionContext.baseURL.isEmpty ? source?.bookSourceUrl ?? "" : executionContext.baseURL) let prepared = try urlBuilder.prepare(rawURL, baseURL: base, defaultHeaders: headers) if prepared.useWebView || prepared.webJavaScript != nil { throw RuleEngineError.unsupportedRule(rule: rawURL, feature: "JS 内 WKWebView 请求") } let milliseconds = Self.number(timeoutValue) let timeout = milliseconds.map { min(max($0 / 1_000, 1), 180) } ?? min(max(Double(source?.respondTime ?? 30_000) / 1_000, 5), 180) var request = prepared.urlRequest(timeout: timeout, handleCookies: source?.enabledCookieJar == true) if source?.enabledCookieJar == true { let stored = LegadoCookieStore.shared.cookieHeader(for: prepared.url.absoluteString) let explicit = request.value(forHTTPHeaderField: "Cookie") ?? "" let merged = Self.mergeCookieHeaders(stored, explicit) if !merged.isEmpty { request.setValue(merged, forHTTPHeaderField: "Cookie") } } let retryCount = max(prepared.retry, 0) let attempts = retryCount == Int.max ? Int.max : retryCount + 1 var lastError: Error = HTTPClientError.nonHTTPResponse for attempt in 0.. String = { key in BookSourceRuntimeStore.shared.get(sourceID: sourceID, key: key) } let put: @convention(block) (String, Any?) -> String = { key, value in let text = Self.stringify(value) BookSourceRuntimeStore.shared.put(sourceID: sourceID, key: key, value: text) return text } let getLoginHeader: @convention(block) () -> Any? = { let value = BookSourceRuntimeStore.shared.get(sourceID: sourceID, key: "loginHeader") return value.isEmpty ? nil : value } let putLoginHeader: @convention(block) (Any?) -> String = { let value = Self.stringify($0) BookSourceRuntimeStore.shared.put(sourceID: sourceID, key: "loginHeader", value: value) return value } let getLoginInfo: @convention(block) () -> [String: Any] = { let value = BookSourceRuntimeStore.shared.get(sourceID: sourceID, key: "loginInfo") guard let data = value.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] } return object } let putLoginInfo: @convention(block) (Any?) -> String = { let value = Self.stringify($0) BookSourceRuntimeStore.shared.put(sourceID: sourceID, key: "loginInfo", value: value) return value } let getHeaderMap: @convention(block) (Bool) -> [String: String] = { includeLogin in var headers: [String: String] = [:] if let raw = source?.header?.trimmingCharacters(in: .whitespacesAndNewlines), raw.hasPrefix("{"), let data = raw.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { headers = object.mapValues { Self.stringify($0) } } if includeLogin { headers.merge(BookSourceRuntimeStore.shared.loginHeaders(sourceID: sourceID)) { _, login in login } } return headers } object?.setObject(get, forKeyedSubscript: "get" as NSString) object?.setObject(put, forKeyedSubscript: "put" as NSString) object?.setObject(getLoginHeader, forKeyedSubscript: "getLoginHeader" as NSString) object?.setObject(putLoginHeader, forKeyedSubscript: "putLoginHeader" as NSString) object?.setObject(getLoginInfo, forKeyedSubscript: "__getLoginInfoMap" as NSString) object?.setObject(putLoginInfo, forKeyedSubscript: "putLoginInfo" as NSString) object?.setObject(getHeaderMap, forKeyedSubscript: "getHeaderMap" as NSString) object?.setObject(source?.bookSourceName ?? "", forKeyedSubscript: "bookSourceName" as NSString) object?.setObject(source?.bookSourceUrl ?? "", forKeyedSubscript: "bookSourceUrl" as NSString) object?.setObject(source?.bookSourceGroup ?? "", forKeyedSubscript: "bookSourceGroup" as NSString) object?.setObject(source?.bookSourceType ?? 0, forKeyedSubscript: "bookSourceType" as NSString) object?.setObject(source?.bookUrlPattern ?? "", forKeyedSubscript: "bookUrlPattern" as NSString) object?.setObject(source?.customOrder ?? 0, forKeyedSubscript: "customOrder" as NSString) object?.setObject(source?.enabled ?? false, forKeyedSubscript: "enabled" as NSString) object?.setObject(source?.enabledExplore ?? false, forKeyedSubscript: "enabledExplore" as NSString) object?.setObject(source?.header ?? "", forKeyedSubscript: "header" as NSString) object?.setObject(source?.loginUrl ?? "", forKeyedSubscript: "loginUrl" as NSString) object?.setObject(source?.searchUrl ?? "", forKeyedSubscript: "searchUrl" as NSString) object?.setObject(source?.exploreUrl ?? "", forKeyedSubscript: "exploreUrl" as NSString) object?.setObject(source?.respondTime ?? 0, forKeyedSubscript: "respondTime" as NSString) object?.setObject(source?.weight ?? 0, forKeyedSubscript: "weight" as NSString) context.setObject(object, forKeyedSubscript: "source" as NSString) context.evaluateScript(""" (function () { source.getKey = function () { return source.bookSourceUrl; }; source.getTag = function () { return source.bookSourceName; }; source.getLoginInfoMap = function () { var map = source.__getLoginInfoMap() || {}; map.get = function (key) { return map[String(key)]; }; map.put = function (key, value) { map[String(key)] = value; return value; }; map.putAll = function (other) { for (var key in other) if (typeof other[key] !== 'function') map[key] = other[key]; }; return map; }; source.login = function () { if (!source.loginUrl) return null; (0, eval)(String(source.loginUrl)); return typeof login === 'function' ? login() : null; }; })(); """) } private func installBook(in context: JSContext, book: Book?) { let object = JSValue(newObjectIn: context) let getName: @convention(block) () -> String = { book?.name ?? "" } let getAuthor: @convention(block) () -> String = { book?.author ?? "" } let getBookURL: @convention(block) () -> String = { book?.bookUrl ?? "" } let getCoverURL: @convention(block) () -> String = { book?.coverUrl ?? "" } let getTotal: @convention(block) () -> Int = { book?.totalChapterNum ?? 0 } object?.setObject(getName, forKeyedSubscript: "getName" as NSString) object?.setObject(getAuthor, forKeyedSubscript: "getAuthor" as NSString) object?.setObject(getBookURL, forKeyedSubscript: "getBookUrl" as NSString) object?.setObject(getCoverURL, forKeyedSubscript: "getCoverUrl" as NSString) object?.setObject(getTotal, forKeyedSubscript: "getTotalChapterNum" as NSString) object?.setObject(book?.name ?? "", forKeyedSubscript: "name" as NSString) object?.setObject(book?.author ?? "", forKeyedSubscript: "author" as NSString) object?.setObject(book?.bookUrl ?? "", forKeyedSubscript: "bookUrl" as NSString) object?.setObject(book?.tocUrl ?? "", forKeyedSubscript: "tocUrl" as NSString) object?.setObject(book?.origin ?? "", forKeyedSubscript: "origin" as NSString) object?.setObject(book?.originName ?? "", forKeyedSubscript: "originName" as NSString) object?.setObject(book?.kind ?? "", forKeyedSubscript: "kind" as NSString) object?.setObject(book?.coverUrl ?? "", forKeyedSubscript: "coverUrl" as NSString) object?.setObject(book?.intro ?? "", forKeyedSubscript: "intro" as NSString) object?.setObject(book?.type ?? 0, forKeyedSubscript: "type" as NSString) object?.setObject(book?.group ?? 0, forKeyedSubscript: "group" as NSString) object?.setObject(book?.latestChapterTitle ?? "", forKeyedSubscript: "latestChapterTitle" as NSString) object?.setObject(book?.totalChapterNum ?? 0, forKeyedSubscript: "totalChapterNum" as NSString) object?.setObject(book?.durChapterIndex ?? 0, forKeyedSubscript: "durChapterIndex" as NSString) object?.setObject(book?.durChapterTitle ?? "", forKeyedSubscript: "durChapterTitle" as NSString) object?.setObject(book?.durChapterPos ?? 0, forKeyedSubscript: "durChapterPos" as NSString) object?.setObject(book?.wordCount ?? "", forKeyedSubscript: "wordCount" as NSString) object?.setObject(book?.variable ?? "", forKeyedSubscript: "variable" as NSString) context.setObject(object, forKeyedSubscript: "book" as NSString) } private func installChapter(in context: JSContext, chapter: BookChapter?) { let object = JSValue(newObjectIn: context) let getTitle: @convention(block) () -> String = { chapter?.title ?? "" } let getIndex: @convention(block) () -> Int = { chapter?.index ?? 0 } let getURL: @convention(block) () -> String = { chapter?.url ?? "" } object?.setObject(getTitle, forKeyedSubscript: "getTitle" as NSString) object?.setObject(getIndex, forKeyedSubscript: "getIndex" as NSString) object?.setObject(getURL, forKeyedSubscript: "getUrl" as NSString) object?.setObject(chapter?.title ?? "", forKeyedSubscript: "title" as NSString) object?.setObject(chapter?.index ?? 0, forKeyedSubscript: "index" as NSString) object?.setObject(chapter?.url ?? "", forKeyedSubscript: "url" as NSString) object?.setObject(chapter?.baseUrl ?? "", forKeyedSubscript: "baseUrl" as NSString) object?.setObject(chapter?.bookUrl ?? "", forKeyedSubscript: "bookUrl" as NSString) object?.setObject(chapter?.isVolume ?? false, forKeyedSubscript: "isVolume" as NSString) object?.setObject(chapter?.isVip ?? false, forKeyedSubscript: "isVip" as NSString) object?.setObject(chapter?.isPay ?? false, forKeyedSubscript: "isPay" as NSString) object?.setObject(chapter?.resourceUrl ?? "", forKeyedSubscript: "resourceUrl" as NSString) object?.setObject(chapter?.tag ?? "", forKeyedSubscript: "tag" as NSString) object?.setObject(chapter?.wordCount ?? "", forKeyedSubscript: "wordCount" as NSString) object?.setObject(chapter?.variable ?? "", forKeyedSubscript: "variable" as NSString) context.setObject(object, forKeyedSubscript: "chapter" as NSString) context.setObject(chapter?.title ?? "", forKeyedSubscript: "title" as NSString) } private func evaluateStringRule( _ rule: String, content: JSONValue, isURL: Bool, executionContext: RuleExecutionContext ) throws -> String { let trimmedRule = rule.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedRule.isEmpty else { return "" } if case .object(let object) = content, let direct = object[trimmedRule] { return resolveJavaScriptRuleURL(direct.stringValue, enabled: isURL, context: executionContext) } let plan = try RuleExpressionParser().parse(trimmedRule) var branchResults: [[String]] = [] for branch in plan.branches { var values: [String] switch branch.mode { case .css: values = try HTMLRuleEvaluator().extractCSS( branch.expression, html: content.stringValue, baseURL: executionContext.baseURL, isFragment: executionContext.isHTMLFragment ) case .xpath: values = try HTMLRuleEvaluator().extractXPath(branch.expression, html: content.stringValue, isFragment: executionContext.isHTMLFragment) case .jsonPath: let json: JSONValue if case .string(let text) = content { json = try JSONValue.decode(data: Data(text.utf8)) } else { json = content } let path = branch.expression.hasPrefix("$") ? branch.expression : "." + branch.expression let normalizedPath = path.hasPrefix("$") ? path : "$" + path values = try JSONPathEvaluator().evaluate(path: normalizedPath, in: json) .map(\.stringValue) case .regex: values = try RegexEvaluator().captureGroups(pattern: branch.expression, input: content.stringValue) .map { $0.count > 1 ? $0.dropFirst().joined() : $0.first ?? "" } case .javaScript: values = [try evaluate( script: branch.expression, result: content, executionContext: executionContext ).value.stringValue] case .webJavaScript: throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "java.getString WebView JavaScript") } if let pattern = branch.replacePattern, !pattern.isEmpty { values = try values.map { try RegexEvaluator().replacing( pattern: pattern, in: $0, with: branch.replacement, firstOnly: branch.replaceFirst ) } } branchResults.append(values) if plan.combination == .fallback, !values.isEmpty { break } } let combined: [String] switch plan.combination { case .single, .concatenate: combined = branchResults.flatMap { $0 } case .fallback: combined = branchResults.first(where: { !$0.isEmpty }) ?? [] case .interleave: guard let firstCount = branchResults.first?.count else { return "" } combined = (0.. String { guard enabled else { return value } let baseText = context.baseURL.isEmpty ? context.source?.bookSourceUrl ?? "" : context.baseURL guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return baseText } guard let baseURL = URL(string: baseText), let absolute = URL(string: value, relativeTo: baseURL)?.absoluteURL else { return value } return absolute.absoluteString } private func rejectUnboundedExecution(_ script: String) throws { let compact = script.replacingOccurrences(of: #"\s+"#, with: "", options: .regularExpression).lowercased() let rejected = ["while(true)", "while(1)", "for(;;)", "eval(", "functionconstructor("] if let token = rejected.first(where: compact.contains) { throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 包含未受控执行结构:\(token)") } } private static func stringify(_ value: Any?) -> String { guard let value else { return "" } if let value = value as? String { return value } if JSONSerialization.isValidJSONObject(value), let data = try? JSONSerialization.data(withJSONObject: value), let string = String(data: data, encoding: .utf8) { return string } return String(describing: value) } private static func firstURL(_ value: Any?) -> String { if let values = value as? [Any], let first = values.first { return stringify(first) } return stringify(value) } private static func number(_ value: Any?) -> Double? { if let value = value as? NSNumber { return value.doubleValue } return Double(stringify(value)) } private static func hexBytes(_ value: String) -> [UInt8] { let characters = Array(value.trimmingCharacters(in: .whitespacesAndNewlines)) guard characters.count.isMultiple(of: 2) else { return [] } return stride(from: 0, to: characters.count, by: 2).compactMap { index in UInt8(String(characters[index ... index + 1]), radix: 16) } } private static func byteData(from value: Any?) -> Data { let values: [Any] if let value = value as? [Any] { values = value } else if let value = value as? NSArray { values = value.map { $0 } } else { return Data() } return Data(values.compactMap { item in if let number = item as? NSNumber { return UInt8(truncatingIfNeeded: number.intValue) } if let number = Int(stringify(item)) { return UInt8(truncatingIfNeeded: number) } return nil }) } private static let javaImporterBootstrap = #""" (function (global) { 'use strict'; var NativeString = global.String; global.__legadoUnboxString = function (value) { return Object.prototype.toString.call(value) === '[object String]' ? NativeString(value) : value; }; function javaClass(name, value) { Object.defineProperty(value, '__javaSimpleName', { value: name }); return Object.freeze(value); } function checkedBytes(value) { if (!Array.isArray(value)) throw new TypeError('Java byte[] required'); return value; } function JavaString(value) { var text = new NativeString(Array.isArray(value) ? __legadoUTF8String(value) : (value == null ? '' : value)); Object.defineProperty(text, 'getBytes', { value: function (charset) { if (charset && !/^utf-?8$/i.test(NativeString(charset))) { throw new Error('Unsupported charset: ' + charset); } return __legadoUTF8Bytes(NativeString(this)); } }); return text; } function SecretKeySpec(bytes, algorithm) { if (!(this instanceof SecretKeySpec)) return new SecretKeySpec(bytes, algorithm); this.key = checkedBytes(bytes).slice(); this.algorithm = NativeString(algorithm || ''); Object.freeze(this.key); Object.freeze(this); } function IvParameterSpec(bytes) { if (!(this instanceof IvParameterSpec)) return new IvParameterSpec(bytes); this.bytes = checkedBytes(bytes).slice(); if (this.bytes.length !== 16) throw new Error('AES IV must contain 16 bytes'); Object.freeze(this.bytes); Object.freeze(this); } var Cipher = javaClass('Cipher', { ENCRYPT_MODE: 1, DECRYPT_MODE: 2, getInstance: function (algorithm) { if (NativeString(algorithm).toUpperCase() !== 'AES/CBC/PKCS5PADDING') { throw new Error('Unsupported Cipher algorithm: ' + algorithm); } var key = null, iv = null, operation = null; return { init: function (mode, spec, parameters) { if ((mode !== 1 && mode !== 2) || !(spec instanceof SecretKeySpec) || spec.algorithm.toUpperCase() !== 'AES' || !(parameters instanceof IvParameterSpec)) { throw new TypeError('AES key, IV and cipher mode required'); } operation = mode; key = spec.key; iv = parameters.bytes; }, doFinal: function (bytes) { if (key === null) throw new Error('Cipher not initialized'); var result = __legadoAESCBC(operation, key, iv, checkedBytes(bytes)); if (!Array.isArray(result)) throw new Error('AES/CBC operation failed'); return result; } }; } }); var Mac = javaClass('Mac', { getInstance: function (algorithm) { if (NativeString(algorithm).toUpperCase() !== 'HMACSHA1') { throw new Error('Unsupported Mac algorithm: ' + algorithm); } var key = null; return { init: function (spec) { if (!(spec instanceof SecretKeySpec) || spec.algorithm.toUpperCase() !== 'HMACSHA1') { throw new TypeError('HmacSHA1 SecretKeySpec required'); } key = spec.key; }, doFinal: function (bytes) { if (key === null) throw new Error('Mac not initialized'); return __legadoHmacSHA1(key, checkedBytes(bytes)); } }; } }); javaClass('SecretKeySpec', SecretKeySpec); javaClass('IvParameterSpec', IvParameterSpec); javaClass('String', JavaString); var Base64 = javaClass('Base64', { DEFAULT: 0, NO_WRAP: 2, encodeToString: function (bytes) { return __legadoBase64EncodeBytes(checkedBytes(bytes)); }, decode: function (value) { return __legadoBase64DecodeBytes(NativeString(value)); }, getDecoder: function () { return Object.freeze({ decode: this.decode }); }, getEncoder: function () { return Object.freeze({ encodeToString: this.encodeToString }); } }); var Arrays = javaClass('Arrays', { copyOfRange: function (bytes, from, to) { checkedBytes(bytes); if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || from > bytes.length || to < from || to - from > 2097152) { throw new RangeError('Invalid byte array range'); } var copied = bytes.slice(from, to); while (copied.length < to - from) copied.push(0); return copied; } }); var URLEncoder = javaClass('URLEncoder', { encode: function (value, charset) { if (charset && !/^utf-?8$/i.test(NativeString(charset))) { throw new Error('Unsupported charset: ' + charset); } return __legadoFormURLEncode(NativeString(value)); } }); var DatatypeConverter = javaClass('DatatypeConverter', { printBase64Binary: function (bytes) { return __legadoBase64EncodeBytes(checkedBytes(bytes)); }, parseBase64Binary: function (value) { return __legadoBase64DecodeBytes(NativeString(value)); }, printHexBinary: function (bytes) { return __legadoHexEncodeBytes(checkedBytes(bytes)); } }); var packages = { javax: { crypto: { Mac: Mac, Cipher: Cipher, spec: { SecretKeySpec: SecretKeySpec, IvParameterSpec: IvParameterSpec } }, xml: { bind: { DatatypeConverter: DatatypeConverter } } }, java: { net: { URLEncoder: URLEncoder }, lang: { String: JavaString }, util: { Base64: Base64, Arrays: Arrays } }, android: { util: { Base64: Base64 } } }; Object.freeze(packages.javax.crypto.spec); Object.freeze(packages.javax.crypto); Object.freeze(packages.javax.xml.bind); Object.freeze(packages.javax.xml); Object.freeze(packages.javax); Object.freeze(packages.java.net); Object.freeze(packages.java.lang); Object.freeze(packages.java.util); Object.freeze(packages.java); Object.freeze(packages.android.util); Object.freeze(packages.android); global.Packages = Object.freeze(packages); global.JavaImporter = Object.freeze(function () { var imported = Object.create(null); var allowedPackages = [packages.java.lang, packages.java.net, packages.java.util, packages.javax.crypto, packages.javax.crypto.spec, packages.javax.xml.bind, packages.android.util]; function addClass(value) { if (!value || typeof value.__javaSimpleName !== 'string') throw new TypeError('Unsupported Java class import'); if (!Object.prototype.hasOwnProperty.call(imported, value.__javaSimpleName)) { Object.defineProperty(imported, value.__javaSimpleName, { value: value, enumerable: true }); } } function addPackage(value) { if (allowedPackages.indexOf(value) < 0) throw new TypeError('Unsupported Java package import'); Object.keys(value).forEach(function (name) { if (value[name] && typeof value[name].__javaSimpleName === 'string') addClass(value[name]); }); } Object.defineProperty(imported, 'importPackage', { value: function () { for (var i = 0; i < arguments.length; i++) addPackage(arguments[i]); }}); Object.defineProperty(imported, 'importClass', { value: function () { for (var i = 0; i < arguments.length; i++) addClass(arguments[i]); }}); for (var index = 0; index < arguments.length; index++) { var value = arguments[index]; if (allowedPackages.indexOf(value) >= 0) addPackage(value); else addClass(value); } return imported; }); })(this); """# private static func formURLEncode(_ value: String) -> String { let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._*")) return value.unicodeScalars.map { scalar in if scalar == " " { return "+" } if scalar.isASCII, allowed.contains(scalar) { return String(scalar) } return String(scalar).utf8.map { String(format: "%%%02X", $0) }.joined() }.joined() } private static func headerMap(_ value: Any?) -> [String: String] { if let values = value as? [String: String] { return values } if let values = value as? [String: Any] { return values.mapValues(stringify) } let text = stringify(value) guard let data = text.data(using: .utf8), let values = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] } return values.mapValues(stringify) } private static func mergeCookieHeaders(_ stored: String, _ explicit: String) -> String { var order: [String] = [] var values: [String: String] = [:] for header in [stored, explicit] { for part in header.split(separator: ";") { let pair = part.split(separator: "=", maxSplits: 1).map(String.init) guard pair.count == 2 else { continue } let name = pair[0].trimmingCharacters(in: .whitespaces) if values[name] == nil { order.append(name) } values[name] = pair[1].trimmingCharacters(in: .whitespaces) } } return order.compactMap { name in values[name].map { "\(name)=\($0)" } }.joined(separator: "; ") } } private protocol VariableBoxProxy: AnyObject { func get(_ key: String) -> String @discardableResult func put(_ key: String, _ value: String) -> String } private final class JSVariableBox: VariableBoxProxy { var values: [String: String] init(_ values: [String: String]) { self.values = values } func get(_ key: String) -> String { values[key] ?? "" } @discardableResult func put(_ key: String, _ value: String) -> String { values[key] = value return value } } public final class BookSourceRuntimeStore: @unchecked Sendable { public static let shared = BookSourceRuntimeStore() private let lock = NSLock() private var values: [String: [String: String]] = [:] private let persistent = PersistentRuntimeCache.shared public func get(sourceID: String, key: String) -> String { lock.lock() defer { lock.unlock() } if let value = values[sourceID]?[key] { return value } let cacheKey = persistentKey(sourceID: sourceID, key: key) let value = persistent.get(cacheKey) ?? "" if !value.isEmpty { values[sourceID, default: [:]][key] = value } return value } public func put(sourceID: String, key: String, value: String) { lock.lock() values[sourceID, default: [:]][key] = value lock.unlock() persistent.put(persistentKey(sourceID: sourceID, key: key), value: value) } public func loginHeaders(sourceID: String) -> [String: String] { let value = get(sourceID: sourceID, key: "loginHeader") guard let data = value.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] } return object.mapValues { String(describing: $0) } } private func persistentKey(sourceID: String, key: String) -> String { key == "loginHeader" ? "loginHeader_\(sourceID)" : "v_\(sourceID)_\(key)" } }