Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -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();
}