import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; import 'package:crypto/crypto.dart'; import 'package:dio/io.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:html/parser.dart' as html; import 'package:html/dom.dart' as dom; import 'package:flutter_qjs/flutter_qjs.dart'; import 'package:pointycastle/api.dart'; import 'package:pointycastle/asn1/asn1_parser.dart'; import 'package:pointycastle/asn1/primitives/asn1_integer.dart'; import 'package:pointycastle/asn1/primitives/asn1_sequence.dart'; import 'package:pointycastle/asymmetric/api.dart'; import 'package:pointycastle/asymmetric/pkcs1.dart'; import 'package:pointycastle/asymmetric/rsa.dart'; import 'package:pointycastle/block/aes.dart'; import 'package:pointycastle/block/modes/cbc.dart'; import 'package:pointycastle/block/modes/cfb.dart'; import 'package:pointycastle/block/modes/ecb.dart'; import 'package:pointycastle/block/modes/ofb.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:uuid/uuid.dart'; import 'package:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/network/app_dio.dart'; import 'package:venera/network/cookie_jar.dart'; import 'comic_source/comic_source.dart'; import 'consts.dart'; import 'log.dart'; class JavaScriptRuntimeException implements Exception { final String message; JavaScriptRuntimeException(this.message); @override String toString() { return "JSException: $message"; } } class JsEngine with _JSEngineApi, _JsUiApi { factory JsEngine() => _cache ?? (_cache = JsEngine._create()); static JsEngine? _cache; JsEngine._create(); FlutterQjs? _engine; bool _closed = true; Dio? _dio; static void reset() { _cache = null; _cache?.dispose(); JsEngine().init(); } void resetDio() { _dio = AppDio(BaseOptions( responseType: ResponseType.plain, validateStatus: (status) => true)); } Future init() async { if (!_closed) { return; } try { _dio ??= AppDio(BaseOptions( responseType: ResponseType.plain, validateStatus: (status) => true)); _cookieJar ??= SingleInstanceCookieJar.instance!; _closed = false; _engine = FlutterQjs(); _engine!.dispatch(); var setGlobalFunc = _engine!.evaluate("(key, value) => { this[key] = value; }"); (setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]); setGlobalFunc(["appVersion", App.version]); setGlobalFunc.free(); var jsInit = await rootBundle.load("assets/init.js"); _engine! .evaluate(utf8.decode(jsInit.buffer.asUint8List()), name: ""); } catch (e, s) { Log.error('JS Engine', 'JS Engine Init Error:\n$e\n$s'); } } Object? _messageReceiver(dynamic message) { try { if (message is Map) { String method = message["method"] as String; switch (method) { case "log": String level = message["level"]; Log.addLog( switch (level) { "error" => LogLevel.error, "warning" => LogLevel.warning, "info" => LogLevel.info, _ => LogLevel.warning }, message["title"], message["content"].toString()); case 'load_data': String key = message["key"]; String dataKey = message["data_key"]; return ComicSource.find(key)?.data[dataKey]; case 'save_data': String key = message["key"]; String dataKey = message["data_key"]; if (dataKey == 'setting') { throw "setting is not allowed to be saved"; } var data = message["data"]; var source = ComicSource.find(key)!; source.data[dataKey] = data; source.saveData(); case 'delete_data': String key = message["key"]; String dataKey = message["data_key"]; var source = ComicSource.find(key); source?.data.remove(dataKey); source?.saveData(); case 'http': return _http(Map.from(message)); case 'html': return handleHtmlCallback(Map.from(message)); case 'convert': return _convert(Map.from(message)); case "random": return _random( message["min"] ?? 0, message["max"] ?? 1, message["type"], ); case "cookie": return handleCookieCallback(Map.from(message)); case "uuid": return const Uuid().v1(); case "load_setting": String key = message["key"]; String settingKey = message["setting_key"]; var source = ComicSource.find(key)!; return source.data["settings"]?[settingKey] ?? source.settings?[settingKey]!['default'] ?? (throw "Setting not found: $settingKey"); case "isLogged": return ComicSource.find(message["key"])!.isLogged; // temporary solution for [setTimeout] function // TODO: implement [setTimeout] in quickjs project case "delay": return Future.delayed(Duration(milliseconds: message["time"])); case "UI": handleUIMessage(Map.from(message)); } } return null; } catch (e, s) { Log.error("Failed to handle message: $message\n$e\n$s", "JsEngine"); rethrow; } } Future> _http(Map req) async { Response? response; String? error; try { var headers = Map.from(req["headers"] ?? {}); if (headers["user-agent"] == null && headers["User-Agent"] == null) { headers["User-Agent"] = webUA; } var dio = _dio; if (headers['http_client'] == "dart:io") { dio = Dio(BaseOptions( responseType: ResponseType.plain, validateStatus: (status) => true, )); var proxy = await AppDio.getProxy(); dio.httpClientAdapter = IOHttpClientAdapter( createHttpClient: () { return HttpClient() ..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy"; }, ); dio.interceptors .add(CookieManagerSql(SingleInstanceCookieJar.instance!)); dio.interceptors.add(LogInterceptor()); } response = await dio!.request(req["url"], data: req["data"], options: Options( method: req['http_method'], responseType: req["bytes"] == true ? ResponseType.bytes : ResponseType.plain, headers: headers)); } catch (e) { error = e.toString(); } Map headers = {}; response?.headers .forEach((name, values) => headers[name] = values.join(',')); dynamic body = response?.data; if (body is! Uint8List && body is List) { body = Uint8List.fromList(body); } return { "status": response?.statusCode, "headers": headers, "body": body, "error": error, }; } dynamic runCode(String js, [String? name]) { return _engine!.evaluate(js, name: name); } void dispose() { _cache = null; _closed = true; _engine?.close(); _engine?.port.close(); } } mixin class _JSEngineApi { CookieJarSql? _cookieJar; final _documents = {}; Object? handleHtmlCallback(Map data) { switch (data["function"]) { case "parse": if (_documents.length > 8) { var shouldDelete = _documents.keys.first; Log.warning( "JS Engine", "Too many documents, deleting the oldest: $shouldDelete\n" "Current documents: ${_documents.keys}", ); _documents.remove(shouldDelete); } _documents[data["key"]] = DocumentWrapper.parse(data["data"]); return null; case "querySelector": var key = data["key"]; return _documents[key]!.querySelector(data["query"]); case "querySelectorAll": var key = data["key"]; return _documents[key]!.querySelectorAll(data["query"]); case "getText": return _documents[data["doc"]]!.elementGetText(data["key"]); case "getAttributes": var res = _documents[data["doc"]]!.elementGetAttributes(data["key"]); return res; case "dom_querySelector": var doc = _documents[data["doc"]]!; return doc.elementQuerySelector(data["key"], data["query"]); case "dom_querySelectorAll": var doc = _documents[data["doc"]]!; return doc.elementQuerySelectorAll(data["key"], data["query"]); case "getChildren": var doc = _documents[data["doc"]]!; return doc.elementGetChildren(data["key"]); case "getNodes": var doc = _documents[data["doc"]]!; return doc.elementGetNodes(data["key"]); case "getInnerHTML": var doc = _documents[data["doc"]]!; return doc.elementGetInnerHTML(data["key"]); case "getParent": var doc = _documents[data["doc"]]!; return doc.elementGetParent(data["key"]); case "node_text": return _documents[data["doc"]]!.nodeGetText(data["key"]); case "node_type": return _documents[data["doc"]]!.nodeType(data["key"]); case "node_to_element": return _documents[data["doc"]]!.nodeToElement(data["key"]); case "dispose": var docKey = data["key"]; _documents.remove(docKey); return null; case "getClassNames": return _documents[data["doc"]]!.getClassNames(data["key"]); case "getId": return _documents[data["doc"]]!.getId(data["key"]); case "getLocalName": return _documents[data["doc"]]!.getLocalName(data["key"]); case "getElementById": return _documents[data["key"]]!.getElementById(data["id"]); case "getPreviousSibling": return _documents[data["doc"]]!.getPreviousSibling(data["key"]); case "getNextSibling": return _documents[data["doc"]]!.getNextSibling(data["key"]); } return null; } dynamic handleCookieCallback(Map data) { switch (data["function"]) { case "set": _cookieJar!.saveFromResponse( Uri.parse(data["url"]), (data["cookies"] as List).map((e) { var c = Cookie(e["name"], e["value"]); if (e['domain'] != null) { c.domain = e['domain']; } return c; }).toList()); return null; case "get": var cookies = _cookieJar!.loadForRequest(Uri.parse(data["url"])); return cookies .map((e) => { "name": e.name, "value": e.value, "domain": e.domain, "path": e.path, "expires": e.expires, "max-age": e.maxAge, "secure": e.secure, "httpOnly": e.httpOnly, "session": e.expires == null, }) .toList(); case "delete": clearCookies([data["url"]]); return null; } } void clearCookies(List domains) async { for (var domain in domains) { var uri = Uri.tryParse(domain); if (uri == null) continue; _cookieJar!.deleteUri(uri); } } Object? _convert(Map data) { String type = data["type"]; var value = data["value"]; bool isEncode = data["isEncode"]; try { switch (type) { case "utf8": return isEncode ? utf8.encode(value) : utf8.decode(value); case "base64": return isEncode ? base64Encode(value) : base64Decode(value); case "md5": return Uint8List.fromList(md5.convert(value).bytes); case "sha1": return Uint8List.fromList(sha1.convert(value).bytes); case "sha256": return Uint8List.fromList(sha256.convert(value).bytes); case "sha512": return Uint8List.fromList(sha512.convert(value).bytes); case "hmac": var key = data["key"]; var hash = data["hash"]; var hmac = Hmac( switch (hash) { "md5" => md5, "sha1" => sha1, "sha256" => sha256, "sha512" => sha512, _ => throw "Unsupported hash: $hash" }, key); if (data['isString'] == true) { return hmac.convert(value).toString(); } else { return Uint8List.fromList(hmac.convert(value).bytes); } case "aes-ecb": if (!isEncode) { var key = data["key"]; var cipher = ECBBlockCipher(AESEngine()); cipher.init( false, KeyParameter(key), ); var offset = 0; var result = Uint8List(value.length); while (offset < value.length) { offset += cipher.processBlock( value, offset, result, offset, ); } return result; } return null; case "aes-cbc": if (!isEncode) { var key = data["key"]; var iv = data["iv"]; var cipher = CBCBlockCipher(AESEngine()); cipher.init(false, ParametersWithIV(KeyParameter(key), iv)); var offset = 0; var result = Uint8List(value.length); while (offset < value.length) { offset += cipher.processBlock( value, offset, result, offset, ); } return result; } return null; case "aes-cfb": if (!isEncode) { var key = data["key"]; var blockSize = data["blockSize"]; var cipher = CFBBlockCipher(AESEngine(), blockSize); cipher.init(false, KeyParameter(key)); var offset = 0; var result = Uint8List(value.length); while (offset < value.length) { offset += cipher.processBlock( value, offset, result, offset, ); } return result; } return null; case "aes-ofb": if (!isEncode) { var key = data["key"]; var blockSize = data["blockSize"]; var cipher = OFBBlockCipher(AESEngine(), blockSize); cipher.init(false, KeyParameter(key)); var offset = 0; var result = Uint8List(value.length); while (offset < value.length) { offset += cipher.processBlock( value, offset, result, offset, ); } return result; } return null; case "rsa": if (!isEncode) { var key = data["key"]; final cipher = PKCS1Encoding(RSAEngine()); cipher.init(false, PrivateKeyParameter(_parsePrivateKey(key))); return _processInBlocks(cipher, value); } return null; default: return value; } } catch (e, s) { Log.error("JS Engine", "Failed to convert $type: $e", s); return null; } } RSAPrivateKey _parsePrivateKey(String privateKeyString) { List privateKeyDER = base64Decode(privateKeyString); var asn1Parser = ASN1Parser(privateKeyDER as Uint8List); final topLevelSeq = asn1Parser.nextObject() as ASN1Sequence; final privateKey = topLevelSeq.elements![2]; asn1Parser = ASN1Parser(privateKey.valueBytes!); final pkSeq = asn1Parser.nextObject() as ASN1Sequence; final modulus = pkSeq.elements![1] as ASN1Integer; final privateExponent = pkSeq.elements![3] as ASN1Integer; final p = pkSeq.elements![4] as ASN1Integer; final q = pkSeq.elements![5] as ASN1Integer; return RSAPrivateKey( modulus.integer!, privateExponent.integer!, p.integer!, q.integer!); } Uint8List _processInBlocks(AsymmetricBlockCipher engine, Uint8List input) { final numBlocks = input.length ~/ engine.inputBlockSize + ((input.length % engine.inputBlockSize != 0) ? 1 : 0); final output = Uint8List(numBlocks * engine.outputBlockSize); var inputOffset = 0; var outputOffset = 0; while (inputOffset < input.length) { final chunkSize = (inputOffset + engine.inputBlockSize <= input.length) ? engine.inputBlockSize : input.length - inputOffset; outputOffset += engine.processBlock( input, inputOffset, chunkSize, output, outputOffset); inputOffset += chunkSize; } return (output.length == outputOffset) ? output : output.sublist(0, outputOffset); } num _random(num min, num max, String type) { if (type == "double") { return min + (max - min) * math.Random().nextDouble(); } return (min + (max - min) * math.Random().nextDouble()).toInt(); } } class DocumentWrapper { final dom.Document doc; DocumentWrapper.parse(String doc) : doc = html.parse(doc); var elements = []; var nodes = []; int? querySelector(String query) { var element = doc.querySelector(query); if (element == null) return null; elements.add(element); return elements.length - 1; } List querySelectorAll(String query) { var res = doc.querySelectorAll(query); var keys = []; for (var element in res) { elements.add(element); keys.add(elements.length - 1); } return keys; } String? elementGetText(int key) { return elements[key].text; } Map elementGetAttributes(int key) { return elements[key].attributes.map( (key, value) => MapEntry( key.toString(), value, ), ); } String? elementGetInnerHTML(int key) { return elements[key].innerHtml; } int? elementGetParent(int key) { var res = elements[key].parent; if (res == null) return null; elements.add(res); return elements.length - 1; } int? elementQuerySelector(int key, String query) { var res = elements[key].querySelector(query); if (res == null) return null; elements.add(res); return elements.length - 1; } List elementQuerySelectorAll(int key, String query) { var res = elements[key].querySelectorAll(query); var keys = []; for (var element in res) { elements.add(element); keys.add(elements.length - 1); } return keys; } List elementGetChildren(int key) { var res = elements[key].children; var keys = []; for (var element in res) { elements.add(element); keys.add(elements.length - 1); } return keys; } List elementGetNodes(int key) { var res = elements[key].nodes; var keys = []; for (var node in res) { nodes.add(node); keys.add(nodes.length - 1); } return keys; } String? nodeGetText(int key) { return nodes[key].text; } String nodeType(int key) { return switch (nodes[key].nodeType) { dom.Node.ELEMENT_NODE => "element", dom.Node.TEXT_NODE => "text", dom.Node.COMMENT_NODE => "comment", dom.Node.DOCUMENT_NODE => "document", _ => "unknown" }; } int? nodeToElement(int key) { if (nodes[key] is dom.Element) { elements.add(nodes[key] as dom.Element); return elements.length - 1; } return null; } List getClassNames(int key) { return (elements[key]).classes.toList(); } String? getId(int key) { return (elements[key]).id; } String? getLocalName(int key) { return (elements[key]).localName; } int? getElementById(String id) { var element = doc.getElementById(id); if (element == null) return null; elements.add(element); return elements.length - 1; } int? getPreviousSibling(int key) { var res = elements[key].previousElementSibling; if (res == null) return null; elements.add(res); return elements.length - 1; } int? getNextSibling(int key) { var res = elements[key].nextElementSibling; if (res == null) return null; elements.add(res); return elements.length - 1; } } class JSAutoFreeFunction { final JSInvokable func; /// Automatically free the function when it's not used anymore JSAutoFreeFunction(this.func) { finalizer.attach(this, func); } dynamic call(List args) { return func(args); } static final finalizer = Finalizer((func) { func.free(); }); } mixin class _JsUiApi { void handleUIMessage(Map message) { switch (message['function']) { case 'showMessage': var m = message['message']; if (m.toString().isNotEmpty) { App.rootContext.showMessage(message: m.toString()); } case 'showDialog': _showDialog(message); case 'launchUrl': var url = message['url']; if (url.toString().isNotEmpty) { launchUrlString(url.toString()); } } } void _showDialog(Map message) { var title = message['title']; var content = message['content']; var actions = {}; for (var action in message['actions']) { // [message] will be released after the method call, causing the action to be invalid, so we need to duplicate it (action['callback'] as JSInvokable).dup(); actions[action['text']] = JSAutoFreeFunction(action['callback']); } showDialog(context: App.rootContext, builder: (context) { return ContentDialog( title: title, content: Text(content).paddingHorizontal(16), actions: actions.entries.map((entry) { return TextButton( onPressed: () { entry.value.call([]); context.pop(); }, child: Text(entry.key), ); }).toList(), ); }); } }