Initial commit
This commit is contained in:
@@ -0,0 +1,521 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fjs/fjs.dart';
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
import '../../contracts/error.dart';
|
||||
import '../../contracts/script_widget.dart';
|
||||
import '../../domain/errors.dart';
|
||||
import '../../domain/ports/script_widget_runtime.dart';
|
||||
import 'widget_host_bridge.dart';
|
||||
|
||||
const String _tag = 'QuickJsWidgetRuntime';
|
||||
const Duration _defaultInvocationTimeout = Duration(seconds: 30);
|
||||
const int _maximumStackBytes = 1024 * 1024;
|
||||
const int _maximumHeapBytes = 64 * 1024 * 1024;
|
||||
|
||||
|
||||
const JsEvalOptions _scriptEvalOptions = JsEvalOptions.raw(
|
||||
global: true,
|
||||
strict: false,
|
||||
backtraceBarrier: false,
|
||||
promise: true,
|
||||
);
|
||||
|
||||
|
||||
class QuickJsWidgetRuntime implements ScriptWidgetRuntime {
|
||||
final WidgetHostBridge _hostBridge;
|
||||
final Duration invocationTimeout;
|
||||
final Map<String, _LoadedWidgetContext> _contexts =
|
||||
<String, _LoadedWidgetContext>{};
|
||||
|
||||
QuickJsWidgetRuntime({
|
||||
WidgetHostBridge? hostBridge,
|
||||
this.invocationTimeout = _defaultInvocationTimeout,
|
||||
}) : _hostBridge = hostBridge ?? WidgetHostBridge(),
|
||||
assert(invocationTimeout > Duration.zero);
|
||||
|
||||
@override
|
||||
Future<ScriptWidgetManifest> inspectWidget(String scriptSource) async {
|
||||
if (scriptSource.trim().isEmpty) {
|
||||
throw DomainError(ErrorCode.invalidParams, '模块脚本不能为空');
|
||||
}
|
||||
|
||||
return _readManifest(scriptSource);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ScriptWidgetManifest> loadWidget(String scriptSource) async {
|
||||
final manifest = await inspectWidget(scriptSource);
|
||||
|
||||
final bootstrapData = await _hostBridge.loadBootstrapData(manifest.id);
|
||||
final engine = await _createEngine();
|
||||
try {
|
||||
await engine.init(
|
||||
bridge: (JsValue message) => _dispatchHostMessage(manifest.id, message),
|
||||
);
|
||||
await _installHostBootstrap(engine, bootstrapData);
|
||||
await _evaluateOrThrow(engine, scriptSource);
|
||||
final newContext = _LoadedWidgetContext(engine: engine);
|
||||
final existingContext = _contexts[manifest.id];
|
||||
_contexts[manifest.id] = newContext;
|
||||
existingContext?.dispose();
|
||||
return manifest;
|
||||
} catch (_) {
|
||||
unawaited(engine.close().catchError((_) {}));
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool isWidgetLoaded(String widgetId) => _contexts.containsKey(widgetId);
|
||||
|
||||
Future<ScriptWidgetManifest> _readManifest(String scriptSource) async {
|
||||
final engine = await _createEngine();
|
||||
try {
|
||||
await engine.initWithoutBridge();
|
||||
await _evaluateOrThrow(
|
||||
engine,
|
||||
|
||||
|
||||
'var Widget = {}; '
|
||||
'var module = { exports: {} }; '
|
||||
'var exports = module.exports;',
|
||||
);
|
||||
await _evaluateOrThrow(engine, scriptSource);
|
||||
final metadataResult = await _evaluateOrThrow(
|
||||
engine,
|
||||
"typeof WidgetMetadata === 'object' && WidgetMetadata !== null "
|
||||
'? JSON.stringify(WidgetMetadata) : null',
|
||||
);
|
||||
final rawMetadata = metadataResult.value;
|
||||
final decodedMetadata = rawMetadata is String
|
||||
? jsonDecode(rawMetadata)
|
||||
: null;
|
||||
if (decodedMetadata is! Map) {
|
||||
throw DomainError(ErrorCode.invalidParams, '脚本缺少有效的 WidgetMetadata 对象');
|
||||
}
|
||||
final manifest = ScriptWidgetManifest.fromJson(
|
||||
Map<String, dynamic>.from(decodedMetadata),
|
||||
);
|
||||
if (manifest.id.trim().isEmpty || manifest.title.trim().isEmpty) {
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'WidgetMetadata.id 和 title 不能为空',
|
||||
);
|
||||
}
|
||||
return manifest;
|
||||
} on DomainError {
|
||||
rethrow;
|
||||
} catch (error) {
|
||||
throw DomainError(ErrorCode.invalidParams, '无法解析模块元数据', details: error);
|
||||
} finally {
|
||||
unawaited(engine.close().catchError((_) {}));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Object?> invokeModuleFunction(
|
||||
String widgetId,
|
||||
String functionName,
|
||||
Map<String, Object?> params,
|
||||
) async {
|
||||
final context = _contexts[widgetId];
|
||||
if (context == null) {
|
||||
throw DomainError(ErrorCode.storeNotFound, '模块尚未加载:$widgetId');
|
||||
}
|
||||
if (functionName.trim().isEmpty) {
|
||||
throw DomainError(ErrorCode.invalidParams, '模块函数名不能为空');
|
||||
}
|
||||
|
||||
|
||||
_drainBackgroundErrors(context.engine, widgetId);
|
||||
|
||||
final encodedFunctionName = jsonEncode(functionName);
|
||||
final rawArgument = params['__forwardRawArgument'];
|
||||
final encodedArgument = rawArgument == null
|
||||
? jsonEncode(params)
|
||||
: jsonEncode(rawArgument);
|
||||
final JsValue invocationResult;
|
||||
try {
|
||||
invocationResult = await context.engine
|
||||
.eval(
|
||||
source: JsCode.code('''
|
||||
await (async function() {
|
||||
var functionName = $encodedFunctionName;
|
||||
var moduleFunction = globalThis[functionName];
|
||||
if (typeof moduleFunction !== 'function') {
|
||||
throw new Error('Module function not found: ' + functionName);
|
||||
}
|
||||
return await moduleFunction($encodedArgument);
|
||||
})();
|
||||
'''),
|
||||
options: _scriptEvalOptions,
|
||||
)
|
||||
.timeout(invocationTimeout);
|
||||
} on TimeoutException catch (error) {
|
||||
await unloadWidget(widgetId);
|
||||
throw _timeoutError(widgetId, functionName, error);
|
||||
} on DomainError {
|
||||
rethrow;
|
||||
} catch (error) {
|
||||
await unloadWidget(widgetId);
|
||||
throw _executionError(widgetId, functionName, error.toString());
|
||||
}
|
||||
return normalizeQuickJsValue(invocationResult.value);
|
||||
}
|
||||
|
||||
DomainError _timeoutError(
|
||||
String widgetId,
|
||||
String functionName,
|
||||
Object error,
|
||||
) {
|
||||
return DomainError(
|
||||
ErrorCode.serverTimeout,
|
||||
'模块执行超过 ${invocationTimeout.inSeconds} 秒,已终止运行环境',
|
||||
retryable: true,
|
||||
details: <String, Object?>{
|
||||
'widgetId': widgetId,
|
||||
'functionName': functionName,
|
||||
'error': error.toString(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> unloadWidget(String widgetId) async {
|
||||
_contexts.remove(widgetId)?.dispose();
|
||||
_hostBridge.clearWidget(widgetId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
final contexts = _contexts.values.toList(growable: false);
|
||||
_contexts.clear();
|
||||
for (final context in contexts) {
|
||||
context.dispose();
|
||||
}
|
||||
_hostBridge.clear();
|
||||
}
|
||||
|
||||
Future<JsResult> _dispatchHostMessage(String widgetId, JsValue message) async {
|
||||
try {
|
||||
final decoded = message.value;
|
||||
final request = decoded is Map
|
||||
? Map<String, dynamic>.from(decoded)
|
||||
: const <String, dynamic>{};
|
||||
final channel = request['channel']?.toString() ?? '';
|
||||
final rawPayload = request['payload'];
|
||||
final result = await _hostBridge.handleMessage(
|
||||
widgetId,
|
||||
channel,
|
||||
rawPayload is Map
|
||||
? Map<String, dynamic>.from(rawPayload)
|
||||
: <String, dynamic>{},
|
||||
);
|
||||
return JsResult.ok(JsValue.from(result));
|
||||
} catch (error) {
|
||||
return JsResult.err(JsError.bridge(error.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
void _drainBackgroundErrors(JsEngine engine, String widgetId) {
|
||||
for (final backgroundError in engine.drainUnhandledJobErrors()) {
|
||||
AppLogger.warn(_tag, '[$widgetId] 脚本后台异步错误:$backgroundError');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void>? _libFjsInit;
|
||||
|
||||
Future<void> _ensureLibFjsReady() {
|
||||
return _libFjsInit ??= LibFjs.init().then<void>(
|
||||
(_) {},
|
||||
onError: (Object error) {
|
||||
if (error is StateError) {
|
||||
|
||||
|
||||
return;
|
||||
}
|
||||
_libFjsInit = null;
|
||||
throw DomainError(
|
||||
ErrorCode.internalError,
|
||||
'脚本引擎初始化失败',
|
||||
details: error.toString(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<JsEngine> _createEngine() async {
|
||||
await _ensureLibFjsReady();
|
||||
return JsEngine.create(
|
||||
|
||||
|
||||
builtins: const JsBuiltinOptions(timers: true, url: true),
|
||||
runtimeOptions: JsEngineRuntimeOptions(
|
||||
memoryLimit: BigInt.from(_maximumHeapBytes),
|
||||
maxStackSize: BigInt.from(_maximumStackBytes),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _LoadedWidgetContext {
|
||||
final JsEngine engine;
|
||||
|
||||
const _LoadedWidgetContext({required this.engine});
|
||||
|
||||
void dispose() {
|
||||
|
||||
|
||||
unawaited(engine.close().catchError((_) {}));
|
||||
}
|
||||
}
|
||||
|
||||
Future<JsValue> _evaluateOrThrow(JsEngine engine, String source) async {
|
||||
try {
|
||||
return await engine.eval(
|
||||
source: JsCode.code(source),
|
||||
options: _scriptEvalOptions,
|
||||
);
|
||||
} on DomainError {
|
||||
rethrow;
|
||||
} catch (error) {
|
||||
throw DomainError(
|
||||
ErrorCode.invalidParams,
|
||||
'模块脚本执行失败',
|
||||
details: error.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@visibleForTesting
|
||||
Object? normalizeQuickJsValue(Object? value) {
|
||||
if (value is Map) {
|
||||
return <String, dynamic>{
|
||||
for (final entry in value.entries)
|
||||
entry.key?.toString() ?? '': normalizeQuickJsValue(entry.value),
|
||||
};
|
||||
}
|
||||
if (value is List) {
|
||||
return value.map(normalizeQuickJsValue).toList(growable: false);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
DomainError _executionError(
|
||||
String widgetId,
|
||||
String functionName,
|
||||
String details,
|
||||
) {
|
||||
return DomainError(
|
||||
ErrorCode.internalError,
|
||||
'模块函数执行失败:$functionName',
|
||||
details: <String, Object?>{
|
||||
'widgetId': widgetId,
|
||||
'functionName': functionName,
|
||||
'error': details,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<String>? _cheerioBundle;
|
||||
|
||||
Future<String> _cheerioBundleSource() =>
|
||||
_cheerioBundle ??= rootBundle.loadString('assets/js/cheerio.min.js');
|
||||
|
||||
|
||||
Future<void> _installHostBootstrap(
|
||||
JsEngine engine,
|
||||
WidgetHostBootstrapData bootstrapData,
|
||||
) async {
|
||||
final encodedBootData = jsonEncode(<String, Object?>{
|
||||
'storage': bootstrapData.storage,
|
||||
'sharedCache': bootstrapData.sharedCache,
|
||||
});
|
||||
await _evaluateOrThrow(
|
||||
engine,
|
||||
'globalThis.__widgetHostBootData = $encodedBootData;',
|
||||
);
|
||||
await _evaluateOrThrow(engine, await _cheerioBundleSource());
|
||||
await _evaluateOrThrow(engine, _kHostBootstrapJs);
|
||||
}
|
||||
|
||||
const String _kHostBootstrapJs = r'''
|
||||
(function() {
|
||||
var bootData = globalThis.__widgetHostBootData || {};
|
||||
var widgetStorageData = bootData.storage || {};
|
||||
var sharedCacheData = bootData.sharedCache || {};
|
||||
delete globalThis.__widgetHostBootData;
|
||||
|
||||
if (typeof globalThis.module === 'undefined') {
|
||||
globalThis.module = { exports: {} };
|
||||
}
|
||||
if (typeof globalThis.exports === 'undefined') {
|
||||
globalThis.exports = globalThis.module.exports;
|
||||
}
|
||||
if (typeof globalThis.global === 'undefined') {
|
||||
globalThis.global = globalThis;
|
||||
}
|
||||
if (typeof globalThis.self === 'undefined') {
|
||||
globalThis.self = globalThis;
|
||||
}
|
||||
|
||||
function hostCall(channel, payload) {
|
||||
return fjs.bridge_call({ channel: channel, payload: payload }).catch(
|
||||
function(rejection) {
|
||||
var message = (rejection instanceof Error)
|
||||
? rejection.message
|
||||
: String(rejection != null ? rejection : 'unknown host error');
|
||||
throw new Error(message);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function hostNotify(channel, payload) {
|
||||
try {
|
||||
hostCall(channel, payload).catch(function() {});
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
function persistStorage() {
|
||||
hostNotify('WidgetStoragePersist', { storage: widgetStorageData });
|
||||
}
|
||||
|
||||
function persistSharedCache() {
|
||||
hostNotify('WidgetSharedCachePersist', { sharedCache: sharedCacheData });
|
||||
}
|
||||
|
||||
var cheerioModule = globalThis.__fjsCheerio;
|
||||
|
||||
function loadHtml(html) {
|
||||
return cheerioModule.load(String(html == null ? '' : html));
|
||||
}
|
||||
|
||||
var domEntries = {};
|
||||
var domNextHandle = 1;
|
||||
function domRegister(doc, node) {
|
||||
var handle = domNextHandle++;
|
||||
domEntries[handle] = { doc: doc, node: node };
|
||||
return handle;
|
||||
}
|
||||
|
||||
function normalizeConsoleValue(value) {
|
||||
if (value instanceof Error) {
|
||||
return {
|
||||
__consoleErrorMarker: true,
|
||||
name: value.name || 'Error',
|
||||
message: value.message || String(value),
|
||||
stack: value.stack || ''
|
||||
};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function forwardConsole(level) {
|
||||
return function() {
|
||||
var values = [];
|
||||
for (var i = 0; i < arguments.length; i++) {
|
||||
values.push(normalizeConsoleValue(arguments[i]));
|
||||
}
|
||||
hostNotify('WidgetConsole', { level: level, values: values });
|
||||
};
|
||||
}
|
||||
globalThis.console = {
|
||||
log: forwardConsole('log'),
|
||||
info: forwardConsole('info'),
|
||||
debug: forwardConsole('debug'),
|
||||
warn: forwardConsole('warn'),
|
||||
error: forwardConsole('error'),
|
||||
trace: forwardConsole('debug')
|
||||
};
|
||||
|
||||
globalThis.Widget = {
|
||||
http: {
|
||||
get: function(url, options) {
|
||||
return hostCall('WidgetHttp', { method: 'GET', url: url, options: options || {} });
|
||||
},
|
||||
post: function(url, body, options) {
|
||||
return hostCall('WidgetHttp', { method: 'POST', url: url, body: body, options: options || {} });
|
||||
},
|
||||
put: function(url, body, options) {
|
||||
return hostCall('WidgetHttp', { method: 'PUT', url: url, body: body, options: options || {} });
|
||||
},
|
||||
delete: function(url, options) {
|
||||
return hostCall('WidgetHttp', { method: 'DELETE', url: url, options: options || {} });
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
get: function(key) { return widgetStorageData[key]; },
|
||||
set: function(key, value) { widgetStorageData[key] = value; persistStorage(); },
|
||||
remove: function(key) { delete widgetStorageData[key]; persistStorage(); },
|
||||
getItem: function(key) { return widgetStorageData[key]; },
|
||||
setItem: function(key, value) { widgetStorageData[key] = value; persistStorage(); },
|
||||
removeItem: function(key) { delete widgetStorageData[key]; persistStorage(); }
|
||||
},
|
||||
sharedCache: {
|
||||
get: function(namespace, key) {
|
||||
var namespaceData = sharedCacheData[namespace] || {};
|
||||
return namespaceData[key];
|
||||
},
|
||||
set: function(namespace, key, value) {
|
||||
if (!sharedCacheData[namespace]) sharedCacheData[namespace] = {};
|
||||
sharedCacheData[namespace][key] = value;
|
||||
persistSharedCache();
|
||||
},
|
||||
remove: function(namespace, key) {
|
||||
if (sharedCacheData[namespace]) delete sharedCacheData[namespace][key];
|
||||
persistSharedCache();
|
||||
}
|
||||
},
|
||||
html: { load: loadHtml },
|
||||
dom: {
|
||||
parse: function(html) {
|
||||
try {
|
||||
return domRegister(loadHtml(html), null);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
select: function(handle, selector) {
|
||||
var entry = domEntries[handle];
|
||||
if (!entry || !selector) return [];
|
||||
try {
|
||||
var matched = entry.node == null
|
||||
? entry.doc(selector)
|
||||
: entry.doc(entry.node).find(selector);
|
||||
return matched.toArray().map(function(node) {
|
||||
return domRegister(entry.doc, node);
|
||||
});
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
text: function(handle) {
|
||||
var entry = domEntries[handle];
|
||||
if (!entry) return '';
|
||||
return entry.node == null
|
||||
? entry.doc('body').text()
|
||||
: entry.doc(entry.node).text();
|
||||
},
|
||||
html: function(handle) {
|
||||
var entry = domEntries[handle];
|
||||
if (!entry) return '';
|
||||
var value = entry.node == null
|
||||
? entry.doc.html()
|
||||
: entry.doc(entry.node).html();
|
||||
return value == null ? '' : value;
|
||||
},
|
||||
attr: function(handle, name) {
|
||||
var entry = domEntries[handle];
|
||||
if (!entry || entry.node == null) return null;
|
||||
var value = entry.doc(entry.node).attr(String(name));
|
||||
return value == null ? null : value;
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
''';
|
||||
@@ -0,0 +1,390 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
|
||||
const int scriptHttpMaximumResponseBytes = 10 * 1024 * 1024;
|
||||
const int _maximumRedirects = 5;
|
||||
const Duration _connectTimeout = Duration(seconds: 15);
|
||||
const Duration _receiveTimeout = Duration(seconds: 30);
|
||||
|
||||
class SafeScriptHttpResponse {
|
||||
final Object? data;
|
||||
final String text;
|
||||
final int statusCode;
|
||||
final String reasonPhrase;
|
||||
final Map<String, String> headers;
|
||||
|
||||
const SafeScriptHttpResponse({
|
||||
required this.data,
|
||||
required this.text,
|
||||
required this.statusCode,
|
||||
required this.reasonPhrase,
|
||||
required this.headers,
|
||||
});
|
||||
}
|
||||
|
||||
class SafeScriptHttpException implements Exception {
|
||||
final String message;
|
||||
|
||||
const SafeScriptHttpException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class SafeScriptHttpClient {
|
||||
Future<SafeScriptHttpResponse> request({
|
||||
required Uri uri,
|
||||
String method = 'GET',
|
||||
Map<String, String> headers = const <String, String>{},
|
||||
Map<String, Object?> queryParameters = const <String, Object?>{},
|
||||
Object? body,
|
||||
bool followRedirects = true,
|
||||
}) async {
|
||||
var currentUri = _appendQueryParameters(uri, queryParameters);
|
||||
var currentMethod = method.toUpperCase();
|
||||
if (!_allowedMethods.contains(currentMethod)) {
|
||||
throw const SafeScriptHttpException('HTTP method is not permitted');
|
||||
}
|
||||
var currentHeaders = _sanitizeRequestHeaders(headers);
|
||||
var currentBody = body;
|
||||
|
||||
for (var redirectCount = 0; ; redirectCount++) {
|
||||
final addresses = await _resolvePublicAddresses(currentUri);
|
||||
final httpClient = _createPinnedHttpClient(currentUri, addresses.first);
|
||||
try {
|
||||
final request = await httpClient.openUrl(currentMethod, currentUri);
|
||||
request.followRedirects = false;
|
||||
currentHeaders.forEach(request.headers.set);
|
||||
_writeRequestBody(request, currentBody, currentHeaders);
|
||||
|
||||
final response = await request.close().timeout(_receiveTimeout);
|
||||
final location = response.headers.value(HttpHeaders.locationHeader);
|
||||
final shouldRedirect =
|
||||
followRedirects &&
|
||||
location != null &&
|
||||
response.isRedirect;
|
||||
if (shouldRedirect) {
|
||||
if (redirectCount >= _maximumRedirects) {
|
||||
throw const SafeScriptHttpException('HTTP redirect limit exceeded');
|
||||
}
|
||||
final redirectedUri = currentUri.resolve(location);
|
||||
final changesOrigin = !_hasSameOrigin(currentUri, redirectedUri);
|
||||
if (changesOrigin) {
|
||||
currentHeaders = _removeSensitiveHeaders(currentHeaders);
|
||||
}
|
||||
if (_redirectsAsGet(response.statusCode, currentMethod)) {
|
||||
currentMethod = 'GET';
|
||||
currentBody = null;
|
||||
currentHeaders = _removeEntityHeaders(currentHeaders);
|
||||
}
|
||||
currentUri = redirectedUri;
|
||||
continue;
|
||||
}
|
||||
|
||||
final responseBytes = await _readLimitedResponse(response);
|
||||
final responseText = _decodeResponseText(responseBytes, response);
|
||||
return SafeScriptHttpResponse(
|
||||
data: _decodeResponseData(responseText, response),
|
||||
text: responseText,
|
||||
statusCode: response.statusCode,
|
||||
reasonPhrase: response.reasonPhrase,
|
||||
headers: _responseHeaders(response),
|
||||
);
|
||||
} finally {
|
||||
httpClient.close(force: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HttpClient _createPinnedHttpClient(Uri uri, InternetAddress address) {
|
||||
final httpClient = HttpClient()
|
||||
..connectionTimeout = _connectTimeout
|
||||
..idleTimeout = _receiveTimeout
|
||||
..findProxy = (_) => 'DIRECT';
|
||||
httpClient.connectionFactory =
|
||||
(Uri connectionUri, String? proxyHost, int? proxyPort) async {
|
||||
if (proxyHost != null || proxyPort != null) {
|
||||
throw const SafeScriptHttpException('HTTP proxies are not permitted');
|
||||
}
|
||||
if (!_hasSameOrigin(uri, connectionUri)) {
|
||||
throw const SafeScriptHttpException('Unexpected connection target');
|
||||
}
|
||||
final socketTask = await Socket.startConnect(
|
||||
address,
|
||||
connectionUri.port,
|
||||
);
|
||||
if (connectionUri.scheme != 'https') return socketTask;
|
||||
|
||||
final Future<Socket> secureSocket = socketTask.socket.then(
|
||||
(socket) => SecureSocket.secure(socket, host: connectionUri.host),
|
||||
);
|
||||
return ConnectionTask.fromSocket<Socket>(
|
||||
secureSocket,
|
||||
socketTask.cancel,
|
||||
);
|
||||
};
|
||||
return httpClient;
|
||||
}
|
||||
|
||||
Future<List<InternetAddress>> _resolvePublicAddresses(Uri uri) async {
|
||||
if (!isValidScriptHttpUri(uri)) {
|
||||
throw const SafeScriptHttpException(
|
||||
'Only valid HTTP(S) URLs are permitted',
|
||||
);
|
||||
}
|
||||
|
||||
final literalAddress = InternetAddress.tryParse(uri.host);
|
||||
final resolvedAddresses = literalAddress == null
|
||||
? await InternetAddress.lookup(uri.host).timeout(_connectTimeout)
|
||||
: <InternetAddress>[literalAddress];
|
||||
return filterPublicScriptAddresses(host: uri.host, addresses: resolvedAddresses);
|
||||
}
|
||||
|
||||
|
||||
@visibleForTesting
|
||||
List<InternetAddress> filterPublicScriptAddresses({
|
||||
required String host,
|
||||
required List<InternetAddress> addresses,
|
||||
}) {
|
||||
if (addresses.isEmpty) {
|
||||
throw SafeScriptHttpException('Could not resolve host: $host');
|
||||
}
|
||||
final publicAddresses = addresses
|
||||
.where(isPublicScriptAddress)
|
||||
.toList(growable: false);
|
||||
if (publicAddresses.isEmpty) {
|
||||
final rejectedAddresses = addresses
|
||||
.map((address) => address.address)
|
||||
.join(', ');
|
||||
throw SafeScriptHttpException(
|
||||
'Private or special-purpose network addresses are not permitted '
|
||||
'(host=$host, addresses=$rejectedAddresses)',
|
||||
);
|
||||
}
|
||||
return publicAddresses;
|
||||
}
|
||||
|
||||
bool isPublicScriptAddress(InternetAddress address) {
|
||||
final bytes = address.rawAddress;
|
||||
if (address.type == InternetAddressType.IPv4) {
|
||||
return _isPublicIpv4(bytes);
|
||||
}
|
||||
if (address.type != InternetAddressType.IPv6 || bytes.length != 16) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
final isIpv4Mapped =
|
||||
bytes.take(10).every((byte) => byte == 0) &&
|
||||
bytes[10] == 0xff &&
|
||||
bytes[11] == 0xff;
|
||||
if (isIpv4Mapped) return _isPublicIpv4(bytes.sublist(12));
|
||||
|
||||
|
||||
final isUnspecifiedOrLoopback = bytes
|
||||
.take(15)
|
||||
.every((byte) => byte == 0) && (bytes[15] == 0 || bytes[15] == 1);
|
||||
if (isUnspecifiedOrLoopback) return false;
|
||||
|
||||
|
||||
if ((bytes[0] & 0xfe) == 0xfc) return false;
|
||||
|
||||
|
||||
if (bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80) return false;
|
||||
|
||||
|
||||
final isGlobalUnicast = (bytes[0] & 0xe0) == 0x20;
|
||||
if (!isGlobalUnicast) return false;
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _isPublicIpv4(List<int> bytes) {
|
||||
if (bytes.length != 4) return false;
|
||||
final first = bytes[0];
|
||||
final second = bytes[1];
|
||||
final third = bytes[2];
|
||||
|
||||
|
||||
if (first == 0 || first == 10 || first == 127) return false;
|
||||
if (first == 169 && second == 254) return false;
|
||||
if (first == 172 && second >= 16 && second <= 31) return false;
|
||||
if (first == 192 && second == 0 && third == 0) return false;
|
||||
if (first == 192 && second == 168) return false;
|
||||
if (first >= 224) return false;
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<Uint8List> _readLimitedResponse(HttpClientResponse response) async {
|
||||
return collectScriptResponseBytes(
|
||||
response.timeout(_receiveTimeout),
|
||||
declaredLength: response.contentLength,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List> collectScriptResponseBytes(
|
||||
Stream<List<int>> responseStream, {
|
||||
int declaredLength = -1,
|
||||
int maximumBytes = scriptHttpMaximumResponseBytes,
|
||||
}) async {
|
||||
if (declaredLength > maximumBytes) {
|
||||
throw const SafeScriptHttpException('HTTP response exceeds 10 MB');
|
||||
}
|
||||
|
||||
final builder = BytesBuilder(copy: false);
|
||||
var receivedBytes = 0;
|
||||
await for (final chunk in responseStream) {
|
||||
receivedBytes += chunk.length;
|
||||
if (receivedBytes > maximumBytes) {
|
||||
throw const SafeScriptHttpException('HTTP response exceeds 10 MB');
|
||||
}
|
||||
builder.add(chunk);
|
||||
}
|
||||
return builder.takeBytes();
|
||||
}
|
||||
|
||||
void _writeRequestBody(
|
||||
HttpClientRequest request,
|
||||
Object? body,
|
||||
Map<String, String> headers,
|
||||
) {
|
||||
if (body == null) return;
|
||||
if (body is List<int>) {
|
||||
request.add(body);
|
||||
return;
|
||||
}
|
||||
if (body is String) {
|
||||
request.write(body);
|
||||
return;
|
||||
}
|
||||
final hasContentType = headers.keys.any(
|
||||
(name) => name.toLowerCase() == HttpHeaders.contentTypeHeader,
|
||||
);
|
||||
if (!hasContentType) {
|
||||
request.headers.contentType = ContentType.json;
|
||||
}
|
||||
request.write(jsonEncode(body));
|
||||
}
|
||||
|
||||
String _decodeResponseText(
|
||||
Uint8List responseBytes,
|
||||
HttpClientResponse response,
|
||||
) {
|
||||
final charset = response.headers.contentType?.charset?.toLowerCase();
|
||||
if (charset == 'iso-8859-1' || charset == 'latin1') {
|
||||
return latin1.decode(responseBytes);
|
||||
}
|
||||
return utf8.decode(responseBytes, allowMalformed: true);
|
||||
}
|
||||
|
||||
Object? _decodeResponseData(String responseText, HttpClientResponse response) {
|
||||
if (responseText.isEmpty) return responseText;
|
||||
final mimeType = response.headers.contentType?.mimeType.toLowerCase() ?? '';
|
||||
final declaredAsJson =
|
||||
mimeType == 'application/json' || mimeType.endsWith('+json');
|
||||
final looksLikeJson =
|
||||
responseText.startsWith('{') || responseText.startsWith('[');
|
||||
if (!declaredAsJson && !looksLikeJson) return responseText;
|
||||
try {
|
||||
return jsonDecode(responseText);
|
||||
} catch (_) {
|
||||
return responseText;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> _responseHeaders(HttpClientResponse response) {
|
||||
final headers = <String, String>{};
|
||||
response.headers.forEach((name, values) {
|
||||
headers[name] = values.join(', ');
|
||||
});
|
||||
return headers;
|
||||
}
|
||||
|
||||
Map<String, String> _sanitizeRequestHeaders(Map<String, String> headers) {
|
||||
return _removeHeaders(headers, const <String>{
|
||||
'connection',
|
||||
'content-length',
|
||||
'host',
|
||||
'proxy-authorization',
|
||||
'proxy-connection',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, String> _removeSensitiveHeaders(Map<String, String> headers) {
|
||||
return _removeHeaders(headers, const <String>{'authorization', 'cookie'});
|
||||
}
|
||||
|
||||
Map<String, String> _removeEntityHeaders(Map<String, String> headers) {
|
||||
return _removeHeaders(headers, const <String>{
|
||||
'content-encoding',
|
||||
'content-language',
|
||||
'content-type',
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, String> _removeHeaders(
|
||||
Map<String, String> headers,
|
||||
Set<String> excludedNames,
|
||||
) {
|
||||
return <String, String>{
|
||||
for (final entry in headers.entries)
|
||||
if (!excludedNames.contains(entry.key.toLowerCase()))
|
||||
entry.key: entry.value,
|
||||
};
|
||||
}
|
||||
|
||||
Uri _appendQueryParameters(Uri uri, Map<String, Object?> queryParameters) {
|
||||
if (queryParameters.isEmpty) return uri;
|
||||
return uri.replace(
|
||||
queryParameters: <String, dynamic>{
|
||||
...uri.queryParametersAll,
|
||||
for (final entry in queryParameters.entries)
|
||||
if (entry.value != null)
|
||||
entry.key: entry.value is Iterable
|
||||
? (entry.value! as Iterable<Object?>)
|
||||
.map((value) => value?.toString() ?? '')
|
||||
.toList(growable: false)
|
||||
: entry.value.toString(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool isValidScriptHttpUri(Uri uri) {
|
||||
return (uri.scheme == 'http' || uri.scheme == 'https') &&
|
||||
uri.hasAuthority &&
|
||||
uri.host.isNotEmpty &&
|
||||
uri.userInfo.isEmpty;
|
||||
}
|
||||
|
||||
const Set<String> _allowedMethods = <String>{
|
||||
'GET',
|
||||
'POST',
|
||||
'PUT',
|
||||
'PATCH',
|
||||
'DELETE',
|
||||
'HEAD',
|
||||
'OPTIONS',
|
||||
};
|
||||
|
||||
bool _redirectsAsGet(int statusCode, String method) {
|
||||
return statusCode == HttpStatus.seeOther ||
|
||||
((statusCode == HttpStatus.movedPermanently ||
|
||||
statusCode == HttpStatus.found) &&
|
||||
method == 'POST');
|
||||
}
|
||||
|
||||
bool _hasSameOrigin(Uri left, Uri right) {
|
||||
return left.scheme == right.scheme &&
|
||||
left.host.toLowerCase() == right.host.toLowerCase() &&
|
||||
left.port == right.port;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../contracts/error.dart';
|
||||
import '../../domain/errors.dart';
|
||||
import '../../domain/ports/script_widget_remote_gateway.dart';
|
||||
import 'safe_script_http_client.dart';
|
||||
|
||||
class ScriptWidgetRemoteClient implements ScriptWidgetRemoteGateway {
|
||||
final SafeScriptHttpClient _httpClient;
|
||||
|
||||
ScriptWidgetRemoteClient({SafeScriptHttpClient? httpClient})
|
||||
: _httpClient = httpClient ?? SafeScriptHttpClient();
|
||||
|
||||
@override
|
||||
Future<String> fetchText(String url) async {
|
||||
final uri = Uri.tryParse(url.trim());
|
||||
if (uri == null) {
|
||||
throw DomainError(ErrorCode.invalidParams, '模块地址必须是有效的 HTTP(S) URL');
|
||||
}
|
||||
try {
|
||||
final response = await _httpClient.request(uri: uri);
|
||||
return response.text;
|
||||
} on DomainError {
|
||||
rethrow;
|
||||
} catch (error) {
|
||||
final isTimeout = error is TimeoutException;
|
||||
throw DomainError(
|
||||
isTimeout ? ErrorCode.serverTimeout : ErrorCode.serverUnreachable,
|
||||
isTimeout ? '下载模块超时' : '无法下载模块',
|
||||
retryable: true,
|
||||
details: <String, Object?>{'url': url, 'error': error.toString()},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../shared/utils/async_lock.dart';
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
import 'safe_script_http_client.dart';
|
||||
|
||||
typedef WidgetHostBootstrapData = ({
|
||||
Map<String, Object?> storage,
|
||||
Map<String, Object?> sharedCache,
|
||||
});
|
||||
|
||||
const String _browserUserAgent =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||||
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
'Chrome/131.0.0.0 Safari/537.36';
|
||||
|
||||
class WidgetHostBridge {
|
||||
static const String _tag = 'ScriptWidgetHost';
|
||||
static const String _storagePrefix = 'smplayer.script_widget.storage.';
|
||||
static const String _sharedCachePrefix =
|
||||
'smplayer.script_widget.shared_cache.';
|
||||
|
||||
final SafeScriptHttpClient _httpClient;
|
||||
final Map<String, AsyncLock> _persistenceLocks = <String, AsyncLock>{};
|
||||
|
||||
WidgetHostBridge({SafeScriptHttpClient? httpClient})
|
||||
: _httpClient = httpClient ?? SafeScriptHttpClient();
|
||||
|
||||
Future<WidgetHostBootstrapData> loadBootstrapData(String widgetId) async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
return (
|
||||
storage: _decodeObjectMap(
|
||||
preferences.getString('$_storagePrefix$widgetId'),
|
||||
),
|
||||
sharedCache: _decodeObjectMap(
|
||||
preferences.getString('$_sharedCachePrefix$widgetId'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<Object?> handleMessage(
|
||||
String widgetId,
|
||||
String channel,
|
||||
Map<String, dynamic> payload,
|
||||
) async {
|
||||
switch (channel) {
|
||||
case 'WidgetHttp':
|
||||
return _handleHttpRequest(payload);
|
||||
case 'WidgetStoragePersist':
|
||||
return _persistJson(
|
||||
'$_storagePrefix$widgetId',
|
||||
_asMap(payload['storage']),
|
||||
);
|
||||
case 'WidgetSharedCachePersist':
|
||||
return _persistJson(
|
||||
'$_sharedCachePrefix$widgetId',
|
||||
_asMap(payload['sharedCache']),
|
||||
);
|
||||
case 'WidgetConsole':
|
||||
final level = payload['level']?.toString() ?? 'log';
|
||||
final rawValues = payload['values'];
|
||||
final message = rawValues is List
|
||||
? rawValues.map(formatConsoleValue).join(' ')
|
||||
: formatConsoleValue(rawValues);
|
||||
switch (level) {
|
||||
case 'error':
|
||||
AppLogger.error(_tag, '[$widgetId] $message', message);
|
||||
case 'warn':
|
||||
AppLogger.warn(_tag, '[$widgetId] $message');
|
||||
case 'info':
|
||||
AppLogger.info(_tag, '[$widgetId] $message');
|
||||
default:
|
||||
AppLogger.debug(_tag, '[$widgetId] $message');
|
||||
}
|
||||
return true;
|
||||
default:
|
||||
throw ArgumentError('未知的宿主通道:$channel');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> _handleHttpRequest(
|
||||
Map<String, dynamic> request,
|
||||
) async {
|
||||
final method = request['method']?.toString().toUpperCase() ?? 'GET';
|
||||
final url = request['url']?.toString() ?? '';
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) {
|
||||
throw ArgumentError('Widget.http only permits HTTP(S) URLs');
|
||||
}
|
||||
final optionsMap = _asMap(request['options']);
|
||||
final scriptHeaders = <String, String>{
|
||||
for (final entry in _asMap(optionsMap['headers']).entries)
|
||||
entry.key: entry.value.toString(),
|
||||
};
|
||||
final hasUserAgent = scriptHeaders.keys.any(
|
||||
(headerName) => headerName.toLowerCase() == 'user-agent',
|
||||
);
|
||||
final headers = <String, String>{
|
||||
if (!hasUserAgent) 'User-Agent': _browserUserAgent,
|
||||
...scriptHeaders,
|
||||
};
|
||||
final queryParameters = <String, Object?>{
|
||||
for (final entry in _asMap(optionsMap['params']).entries)
|
||||
entry.key: entry.value,
|
||||
};
|
||||
final response = await _httpClient.request(
|
||||
uri: uri,
|
||||
method: method,
|
||||
headers: headers,
|
||||
queryParameters: queryParameters,
|
||||
body: request['body'],
|
||||
followRedirects: optionsMap['allow_redirects'] != false,
|
||||
);
|
||||
return <String, Object?>{
|
||||
'data': response.data,
|
||||
'status': response.statusCode,
|
||||
|
||||
|
||||
'statusCode': response.statusCode,
|
||||
'statusText': response.reasonPhrase,
|
||||
'headers': response.headers,
|
||||
};
|
||||
}
|
||||
|
||||
Future<bool> _persistJson(
|
||||
String preferenceKey,
|
||||
Map<String, dynamic> value,
|
||||
) => _persistenceLocks.putIfAbsent(preferenceKey, AsyncLock.new).run(() async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
return preferences.setString(preferenceKey, jsonEncode(value));
|
||||
});
|
||||
|
||||
void clearWidget(String widgetId) {
|
||||
_persistenceLocks.remove('$_storagePrefix$widgetId');
|
||||
_persistenceLocks.remove('$_sharedCachePrefix$widgetId');
|
||||
}
|
||||
|
||||
void clear() => _persistenceLocks.clear();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _asMap(Object? value) {
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) return Map<String, dynamic>.from(value);
|
||||
if (value is String) {
|
||||
try {
|
||||
final decodedValue = jsonDecode(value);
|
||||
if (decodedValue is Map) {
|
||||
return Map<String, dynamic>.from(decodedValue);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
|
||||
Map<String, Object?> _decodeObjectMap(String? rawJson) {
|
||||
if (rawJson == null || rawJson.trim().isEmpty) return <String, Object?>{};
|
||||
try {
|
||||
return _asMap(jsonDecode(rawJson));
|
||||
} catch (_) {
|
||||
return <String, Object?>{};
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
String formatConsoleValue(Object? value) {
|
||||
if (value == null) return 'null';
|
||||
if (value is String) return value;
|
||||
if (value is num || value is bool) return value.toString();
|
||||
if (value is Map) {
|
||||
if (value['__consoleErrorMarker'] == true) {
|
||||
final errorName = value['name']?.toString() ?? 'Error';
|
||||
final errorMessage = value['message']?.toString() ?? '';
|
||||
final errorStack = value['stack']?.toString() ?? '';
|
||||
final formatted = errorMessage.isEmpty
|
||||
? errorName
|
||||
: '$errorName: $errorMessage';
|
||||
return errorStack.isEmpty ? formatted : '$formatted\n$errorStack';
|
||||
}
|
||||
try {
|
||||
return jsonEncode(value);
|
||||
} catch (_) {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
if (value is Iterable) {
|
||||
try {
|
||||
return jsonEncode(value.toList(growable: false));
|
||||
} catch (_) {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
Reference in New Issue
Block a user