Add compute api to js engine.

This commit is contained in:
2025-09-02 22:15:54 +08:00
parent fa2dbd79f6
commit dfee65c3af
9 changed files with 243 additions and 13 deletions

View File

@@ -4,6 +4,18 @@ Venera JavaScript Library
This library provides a set of APIs for interacting with the Venera app. 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) { function setTimeout(callback, delay) {
sendMessage({ sendMessage({
method: 'delay', method: 'delay',
@@ -1412,3 +1424,18 @@ function getClipboard() {
method: 'getClipboard' method: 'getClipboard'
}) })
} }
/**
* 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<any>} - The result of the function.
* @since 1.5.0
*/
function compute(func, args) {
return sendMessage({
method: 'compute',
function: func,
args: args
})
}

View File

@@ -30,6 +30,10 @@ class _App {
bool get isMobile => Platform.isAndroid || Platform.isIOS; 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 get locale {
Locale deviceLocale = PlatformDispatcher.instance.locale; Locale deviceLocale = PlatformDispatcher.instance.locale;
if (deviceLocale.languageCode == "zh" && if (deviceLocale.languageCode == "zh" &&
@@ -81,6 +85,7 @@ class _App {
if (isAndroid) { if (isAndroid) {
externalStoragePath = (await getExternalStorageDirectory())!.path; externalStoragePath = (await getExternalStorageDirectory())!.path;
} }
isInitialized = true;
} }
Future<void> initComponents() async { Future<void> initComponents() async {

View File

@@ -89,8 +89,7 @@ class ComicSourceParser {
} }
var className = line1.split("class")[1].split("extends ComicSource").first; var className = line1.split("class")[1].split("extends ComicSource").first;
className = className.trim(); className = className.trim();
JsEngine().runCode(""" JsEngine().runCode("""(() => { $js
(() => { $js
this['temp'] = new $className() this['temp'] = new $className()
}).call() }).call()
""", className); """, className);

View File

@@ -24,6 +24,7 @@ import 'package:pointycastle/block/modes/ofb.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'package:venera/components/js_ui.dart'; import 'package:venera/components/js_ui.dart';
import 'package:venera/foundation/app.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/app_dio.dart';
import 'package:venera/network/cookie_jar.dart'; import 'package:venera/network/cookie_jar.dart';
import 'package:venera/network/proxy.dart'; import 'package:venera/network/proxy.dart';
@@ -68,6 +69,12 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
responseType: ResponseType.plain, validateStatus: (status) => true)); responseType: ResponseType.plain, validateStatus: (status) => true));
} }
static Uint8List? _jsInitCache;
static void cacheJsInit(Uint8List jsInit) {
_jsInitCache = jsInit;
}
@override @override
@protected @protected
Future<void> doInit() async { Future<void> doInit() async {
@@ -75,9 +82,11 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
return; return;
} }
try { try {
if (App.isInitialized) {
_cookieJar ??= await SingleInstanceCookieJar.createInstance();
}
_dio ??= AppDio(BaseOptions( _dio ??= AppDio(BaseOptions(
responseType: ResponseType.plain, validateStatus: (status) => true)); responseType: ResponseType.plain, validateStatus: (status) => true));
_cookieJar ??= await SingleInstanceCookieJar.createInstance();
_closed = false; _closed = false;
_engine = FlutterQjs(); _engine = FlutterQjs();
_engine!.dispatch(); _engine!.dispatch();
@@ -86,9 +95,15 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
(setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]); (setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]);
setGlobalFunc(["appVersion", App.version]); setGlobalFunc(["appVersion", App.version]);
setGlobalFunc.free(); 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! _engine!
.evaluate(utf8.decode(jsInit.buffer.asUint8List()), name: "<init>"); .evaluate(utf8.decode(jsInit), name: "<init>");
} catch (e, s) { } catch (e, s) {
Log.error('JS Engine', 'JS Engine Init Error:\n$e\n$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) { Object? _messageReceiver(dynamic message) {
try { try {
if (message is Map<dynamic, dynamic>) { if (message is Map<dynamic, dynamic>) {
if (message["method"] == null) return null;
String method = message["method"] as String; String method = message["method"] as String;
switch (method) { switch (method) {
case "log": case "log":
@@ -172,6 +188,20 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
var res = await Clipboard.getData(Clipboard.kTextPlain); var res = await Clipboard.getData(Clipboard.kTextPlain);
return res?.text; 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; return null;

163
lib/foundation/js_pool.dart Normal file
View File

@@ -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<IsolateJsEngine> _instances = [];
bool _isInitializing = false;
static final JSPool _singleton = JSPool._internal();
factory JSPool() {
return _singleton;
}
JSPool._internal();
Future<void> 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<dynamic> execute(String jsFunction, List<dynamic> 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<int, Completer<dynamic>> _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<dynamic> execute(String jsFunction, List<dynamic> args) async {
if (_isClosed) {
throw Exception("IsolateJsEngine is closed.");
}
while (_sendPort == null) {
await Future.delayed(const Duration(milliseconds: 10));
}
final completer = Completer<dynamic>();
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<dynamic> 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);
}

View File

@@ -42,7 +42,7 @@ class Log {
static void addLog(LogLevel level, String title, String content) { static void addLog(LogLevel level, String title, String content) {
if (isMuted) return; if (isMuted) return;
if (_file == null) { if (_file == null && App.isInitialized) {
Directory dir; Directory dir;
if (App.isAndroid) { if (App.isAndroid) {
dir = Directory(App.externalStoragePath!); dir = Directory(App.externalStoragePath!);

View File

@@ -112,10 +112,12 @@ class AppDio with DioMixin {
AppDio([BaseOptions? options]) { AppDio([BaseOptions? options]) {
this.options = options ?? BaseOptions(); this.options = options ?? BaseOptions();
httpClientAdapter = RHttpAdapter(); httpClientAdapter = RHttpAdapter();
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!)); if (App.isInitialized) {
interceptors.add(NetworkCacheManager()); interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
interceptors.add(CloudflareInterceptor()); interceptors.add(NetworkCacheManager());
interceptors.add(MyLogInterceptor()); interceptors.add(CloudflareInterceptor());
interceptors.add(MyLogInterceptor());
}
} }
static final Map<String, bool> _requests = {}; static final Map<String, bool> _requests = {};

View File

@@ -202,9 +202,13 @@ class SingleInstanceCookieJar extends CookieJarSql {
static SingleInstanceCookieJar? instance; static SingleInstanceCookieJar? instance;
static Future<void> createInstance() async { static Future<SingleInstanceCookieJar> createInstance() async {
if (instance != null) {
return instance!;
}
var dataPath = (await getApplicationSupportDirectory()).path; var dataPath = (await getApplicationSupportDirectory()).path;
instance = SingleInstanceCookieJar("$dataPath/cookie.db"); instance = SingleInstanceCookieJar("$dataPath/cookie.db");
return instance!;
} }
} }

View File

@@ -62,7 +62,7 @@ class DebugPageState extends State<DebugPage> {
TextButton( TextButton(
onPressed: () { onPressed: () {
try { try {
var res = JsEngine().runCode(controller.text); var res = JsEngine().runCode(controller.text, "<debug>");
setState(() { setState(() {
result = res.toString(); result = res.toString();
}); });