diff --git a/assets/init.js b/assets/init.js index 7e067cf..7705a84 100644 --- a/assets/init.js +++ b/assets/init.js @@ -4,6 +4,18 @@ Venera JavaScript Library This library provides a set of APIs for interacting with the Venera app. */ +/** + * @function sendMessage + * @global + * @param {Object} message + * @returns {any} + */ + +/** + * Set a timeout to execute a callback function after a specified delay. + * @param callback {Function} + * @param delay {number} - delay in milliseconds + */ function setTimeout(callback, delay) { sendMessage({ method: 'delay', @@ -1411,4 +1423,19 @@ function getClipboard() { return sendMessage({ method: 'getClipboard' }) -} \ No newline at end of file +} + +/** + * Compute a function with arguments. The function will be executed in the engine pool which is not in the main thread. + * @param func {string} - A js code string which can be evaluated to a function. The function will receive the args as its only argument. + * @param args {any[] | null | undefined} - The arguments to pass to the function. + * @returns {Promise} - The result of the function. + * @since 1.5.0 + */ +function compute(func, args) { + return sendMessage({ + method: 'compute', + function: func, + args: args + }) +} diff --git a/lib/foundation/app.dart b/lib/foundation/app.dart index 105b985..6793de9 100644 --- a/lib/foundation/app.dart +++ b/lib/foundation/app.dart @@ -30,6 +30,10 @@ class _App { bool get isMobile => Platform.isAndroid || Platform.isIOS; + // Whether the app has been initialized. + // If current Isolate is main Isolate, this value is always true. + bool isInitialized = false; + Locale get locale { Locale deviceLocale = PlatformDispatcher.instance.locale; if (deviceLocale.languageCode == "zh" && @@ -81,6 +85,7 @@ class _App { if (isAndroid) { externalStoragePath = (await getExternalStorageDirectory())!.path; } + isInitialized = true; } Future initComponents() async { diff --git a/lib/foundation/comic_source/parser.dart b/lib/foundation/comic_source/parser.dart index 219fdd7..923f948 100644 --- a/lib/foundation/comic_source/parser.dart +++ b/lib/foundation/comic_source/parser.dart @@ -89,8 +89,7 @@ class ComicSourceParser { } var className = line1.split("class")[1].split("extends ComicSource").first; className = className.trim(); - JsEngine().runCode(""" - (() => { $js + JsEngine().runCode("""(() => { $js this['temp'] = new $className() }).call() """, className); diff --git a/lib/foundation/js_engine.dart b/lib/foundation/js_engine.dart index 1a91771..551561a 100644 --- a/lib/foundation/js_engine.dart +++ b/lib/foundation/js_engine.dart @@ -24,6 +24,7 @@ import 'package:pointycastle/block/modes/ofb.dart'; import 'package:uuid/uuid.dart'; import 'package:venera/components/js_ui.dart'; import 'package:venera/foundation/app.dart'; +import 'package:venera/foundation/js_pool.dart'; import 'package:venera/network/app_dio.dart'; import 'package:venera/network/cookie_jar.dart'; import 'package:venera/network/proxy.dart'; @@ -68,6 +69,12 @@ class JsEngine with _JSEngineApi, JsUiApi, Init { responseType: ResponseType.plain, validateStatus: (status) => true)); } + static Uint8List? _jsInitCache; + + static void cacheJsInit(Uint8List jsInit) { + _jsInitCache = jsInit; + } + @override @protected Future doInit() async { @@ -75,9 +82,11 @@ class JsEngine with _JSEngineApi, JsUiApi, Init { return; } try { + if (App.isInitialized) { + _cookieJar ??= await SingleInstanceCookieJar.createInstance(); + } _dio ??= AppDio(BaseOptions( responseType: ResponseType.plain, validateStatus: (status) => true)); - _cookieJar ??= await SingleInstanceCookieJar.createInstance(); _closed = false; _engine = FlutterQjs(); _engine!.dispatch(); @@ -86,9 +95,15 @@ class JsEngine with _JSEngineApi, JsUiApi, Init { (setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]); setGlobalFunc(["appVersion", App.version]); setGlobalFunc.free(); - var jsInit = await rootBundle.load("assets/init.js"); + Uint8List jsInit; + if (_jsInitCache != null) { + jsInit = _jsInitCache!; + } else { + var buffer = await rootBundle.load("assets/init.js"); + jsInit = buffer.buffer.asUint8List(); + } _engine! - .evaluate(utf8.decode(jsInit.buffer.asUint8List()), name: ""); + .evaluate(utf8.decode(jsInit), name: ""); } catch (e, s) { Log.error('JS Engine', 'JS Engine Init Error:\n$e\n$s'); } @@ -97,6 +112,7 @@ class JsEngine with _JSEngineApi, JsUiApi, Init { Object? _messageReceiver(dynamic message) { try { if (message is Map) { + if (message["method"] == null) return null; String method = message["method"] as String; switch (method) { case "log": @@ -172,6 +188,20 @@ class JsEngine with _JSEngineApi, JsUiApi, Init { var res = await Clipboard.getData(Clipboard.kTextPlain); return res?.text; }); + case "compute": + final func = message["function"]; + final args = message["args"]; + if (func is JSInvokable) { + func.free(); + throw "Function must be a string"; + } + if (func is! String) { + throw "Function must be a string"; + } + if (args != null && args is! List) { + throw "Args must be a list"; + } + return JSPool().execute(func, args ?? []); } } return null; diff --git a/lib/foundation/js_pool.dart b/lib/foundation/js_pool.dart new file mode 100644 index 0000000..a1861a5 --- /dev/null +++ b/lib/foundation/js_pool.dart @@ -0,0 +1,163 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'package:flutter/services.dart'; +import 'package:flutter_qjs/flutter_qjs.dart'; +import 'package:venera/foundation/js_engine.dart'; +import 'package:venera/foundation/log.dart'; + +class JSPool { + static final int _maxInstances = 4; + final List _instances = []; + bool _isInitializing = false; + + static final JSPool _singleton = JSPool._internal(); + factory JSPool() { + return _singleton; + } + JSPool._internal(); + + Future init() async { + if (_isInitializing) return; + _isInitializing = true; + var jsInitBuffer = await rootBundle.load("assets/init.js"); + var jsInit = jsInitBuffer.buffer.asUint8List(); + for (int i = 0; i < _maxInstances; i++) { + _instances.add(IsolateJsEngine(jsInit)); + } + _isInitializing = false; + } + + Future execute(String jsFunction, List args) async { + await init(); + var selectedInstance = _instances[0]; + for (var instance in _instances) { + if (instance.pendingTasks < selectedInstance.pendingTasks) { + selectedInstance = instance; + } + } + return selectedInstance.execute(jsFunction, args); + } +} + +class _IsolateJsEngineInitParam { + final SendPort sendPort; + + final Uint8List jsInit; + + _IsolateJsEngineInitParam(this.sendPort, this.jsInit); +} + +class IsolateJsEngine { + Isolate? _isolate; + + SendPort? _sendPort; + ReceivePort? _receivePort; + + int _counter = 0; + final Map> _tasks = {}; + + bool _isClosed = false; + + int get pendingTasks => _tasks.length; + + IsolateJsEngine(Uint8List jsInit) { + _receivePort = ReceivePort(); + _receivePort!.listen(_onMessage); + Isolate.spawn(_run, _IsolateJsEngineInitParam(_receivePort!.sendPort, jsInit)); + } + + void _onMessage(dynamic message) { + if (message is SendPort) { + _sendPort = message; + } else if (message is TaskResult) { + final completer = _tasks.remove(message.id); + if (completer != null) { + if (message.error != null) { + completer.completeError(message.error!); + } else { + completer.complete(message.result); + } + } + } else if (message is Exception) { + Log.error("IsolateJsEngine", message.toString()); + for (var completer in _tasks.values) { + completer.completeError(message); + } + _tasks.clear(); + close(); + } + } + + static void _run(_IsolateJsEngineInitParam params) async { + var sendPort = params.sendPort; + final port = ReceivePort(); + sendPort.send(port.sendPort); + final engine = JsEngine(); + try { + JsEngine.cacheJsInit(params.jsInit); + await engine.init(); + } + catch(e, s) { + sendPort.send(Exception("Failed to initialize JS engine: $e\n$s")); + return; + } + await for (final message in port) { + if (message is Task) { + try { + final jsFunc = engine.runCode(message.jsFunction); + if (jsFunc is! JSInvokable) { + throw Exception("The provided code does not evaluate to a function."); + } + final result = jsFunc.invoke(message.args); + jsFunc.free(); + sendPort.send(TaskResult(message.id, result, null)); + } catch (e) { + sendPort.send(TaskResult(message.id, null, e.toString())); + } + } + } + } + + Future execute(String jsFunction, List args) async { + if (_isClosed) { + throw Exception("IsolateJsEngine is closed."); + } + while (_sendPort == null) { + await Future.delayed(const Duration(milliseconds: 10)); + } + final completer = Completer(); + final taskId = _counter++; + _tasks[taskId] = completer; + final task = Task(taskId, jsFunction, args); + _sendPort?.send(task); + return completer.future; + } + + void close() async { + if (!_isClosed) { + _isClosed = true; + while (_tasks.isNotEmpty) { + await Future.delayed(const Duration(milliseconds: 100)); + } + _receivePort?.close(); + _isolate?.kill(priority: Isolate.immediate); + _isolate = null; + } + } +} + +class Task { + final int id; + final String jsFunction; + final List args; + + const Task(this.id, this.jsFunction, this.args); +} + +class TaskResult { + final int id; + final Object? result; + final String? error; + + const TaskResult(this.id, this.result, this.error); +} diff --git a/lib/foundation/log.dart b/lib/foundation/log.dart index 67a8b12..95543cf 100644 --- a/lib/foundation/log.dart +++ b/lib/foundation/log.dart @@ -42,7 +42,7 @@ class Log { static void addLog(LogLevel level, String title, String content) { if (isMuted) return; - if (_file == null) { + if (_file == null && App.isInitialized) { Directory dir; if (App.isAndroid) { dir = Directory(App.externalStoragePath!); diff --git a/lib/network/app_dio.dart b/lib/network/app_dio.dart index 6a583bf..c134ffe 100644 --- a/lib/network/app_dio.dart +++ b/lib/network/app_dio.dart @@ -112,10 +112,12 @@ class AppDio with DioMixin { AppDio([BaseOptions? options]) { this.options = options ?? BaseOptions(); httpClientAdapter = RHttpAdapter(); - interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!)); - interceptors.add(NetworkCacheManager()); - interceptors.add(CloudflareInterceptor()); - interceptors.add(MyLogInterceptor()); + if (App.isInitialized) { + interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!)); + interceptors.add(NetworkCacheManager()); + interceptors.add(CloudflareInterceptor()); + interceptors.add(MyLogInterceptor()); + } } static final Map _requests = {}; diff --git a/lib/network/cookie_jar.dart b/lib/network/cookie_jar.dart index d06cc1e..f362f82 100644 --- a/lib/network/cookie_jar.dart +++ b/lib/network/cookie_jar.dart @@ -202,9 +202,13 @@ class SingleInstanceCookieJar extends CookieJarSql { static SingleInstanceCookieJar? instance; - static Future createInstance() async { + static Future createInstance() async { + if (instance != null) { + return instance!; + } var dataPath = (await getApplicationSupportDirectory()).path; instance = SingleInstanceCookieJar("$dataPath/cookie.db"); + return instance!; } } diff --git a/lib/pages/settings/debug.dart b/lib/pages/settings/debug.dart index 9708633..ad71187 100644 --- a/lib/pages/settings/debug.dart +++ b/lib/pages/settings/debug.dart @@ -62,7 +62,7 @@ class DebugPageState extends State { TextButton( onPressed: () { try { - var res = JsEngine().runCode(controller.text); + var res = JsEngine().runCode(controller.text, ""); setState(() { result = res.toString(); });