import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:fjs/fjs.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart' show ExternalLibrary; import 'package:path/path.dart' as p; import 'plugin_host.dart'; import 'plugin_manifest.dart'; import 'plugin_media_downloader.dart'; import 'plugin_models.dart'; import 'plugin_storage.dart'; class PluginRuntime { PluginRuntime._(); static Future? _initialization; static Future initialize({ExternalLibrary? externalLibrary}) async { final pending = _initialization ??= LibFjs.init( externalLibrary: externalLibrary, ); try { await pending; } catch (_) { if (identical(_initialization, pending)) _initialization = null; rethrow; } } static Future run( PluginCommandDescriptor command, PluginHost host, ) async { await initialize(); final plugin = command.plugin; final storage = PluginStorage(plugin.manifest.id); final sentMessageIds = {}; final engine = await JsEngine.create( builtins: JsBuiltinOptions.none(), runtimeOptions: JsEngineRuntimeOptions( memoryLimit: BigInt.from(16 * 1024 * 1024), gcThreshold: BigInt.from(4 * 1024 * 1024), maxStackSize: BigInt.from(256 * 1024), info: plugin.manifest.id, ), ); var closed = false; Future close() async { if (closed) return; closed = true; await engine.close(); } try { await engine.init( bridge: (value) => _bridge(plugin, host, storage, sentMessageIds, value), ); await engine.declareNewModule( module: JsModule.code(module: 'komet:api', code: _apiModule), ); final sources = await plugin.loadModules(); final prefix = 'plugin:${plugin.manifest.id}/'; await engine.declareNewModules( modules: sources.entries .map( (entry) => JsModule.code( module: '$prefix${entry.key}', code: _rewriteImports(entry.value, prefix, entry.key), ), ) .toList(), ); final moduleName = '$prefix${plugin.manifest.main}'; final call = engine.call( module: moduleName, method: command.command.handler, params: [ JsValue.from({ 'args': host.args, 'arguments': host.arguments, 'reply': plugin.grantedPermissions.contains(PluginPermission.replyRead) ? host.replyMessage : null, 'apiVersion': kPluginApiVersion, }), ], ); await call.timeout( const Duration(seconds: 30), onTimeout: () async { await close(); throw TimeoutException('Плагин превысил лимит времени'); }, ); } finally { await close(); } } static Future _bridge( PluginDescriptor plugin, PluginHost host, PluginStorage storage, Set sentMessageIds, JsValue value, ) async { try { if (!host.isActive) throw StateError('Чат плагина закрыт'); final request = value.value; if (request is! Map) { throw const FormatException('Некорректный вызов API'); } final method = request['method']; final args = request['args']; if (method is! String || args is! List) { throw const FormatException('Некорректный вызов API'); } final Object? result; switch (method) { case 'chat.sendText': result = await _sendText(plugin, host, sentMessageIds, args); case 'chat.editText': await _editText(plugin, host, sentMessageIds, args); result = null; case 'chat.sendPhoto': await _sendPhoto(plugin, host, args); result = null; case 'chat.sendFile': await _sendFile(plugin, host, args); result = null; case 'ui.notify': await _notify(plugin, host, args); result = null; case 'contact.getPeer': result = await _getPeer(plugin, host); case 'runtime.sleep': await _sleep(args); result = null; case 'runtime.isOnline': result = host.isOnline; case 'runtime.isActive': result = host.isActive; case 'network.fetch': result = await _fetch(plugin, args); case 'storage.get': result = await _storageGet(plugin, storage, args); case 'storage.set': await _storageSet(plugin, storage, args); result = null; case 'storage.remove': await _storageRemove(plugin, storage, args); result = null; default: throw FormatException('Неизвестный метод API: $method'); } return JsResult.ok(JsValue.from({'ok': true, 'value': result})); } catch (error) { return JsResult.ok(JsValue.from({'ok': false, 'error': '$error'})); } } static Future _sendText( PluginDescriptor plugin, PluginHost host, Set sentMessageIds, List args, ) async { _require(plugin, PluginPermission.chatWrite); final id = await host.sendText(_stringArg(args, 0)); if (id.isNotEmpty) sentMessageIds.add(id); return id; } static Future _editText( PluginDescriptor plugin, PluginHost host, Set sentMessageIds, List args, ) { _require(plugin, PluginPermission.chatEdit); final messageId = _stringArg(args, 0); if (!sentMessageIds.contains(messageId)) { throw const FormatException( 'Плагин может редактировать только созданные им сообщения', ); } return host.editText(messageId, _stringArg(args, 1)); } static Future _notify( PluginDescriptor plugin, PluginHost host, List args, ) { _require(plugin, PluginPermission.uiNotify); return host.notify(_stringArg(args, 0)); } static Future _sendPhoto( PluginDescriptor plugin, PluginHost host, List args, ) async { _require(plugin, PluginPermission.photoWrite); final options = _mapArg(args, 0); final bytes = await _mediaBytes( plugin, options, maxBytes: 15 * 1024 * 1024, ); await host.sendPhoto( bytes, filename: _filename(options, 'plugin_photo.jpg'), caption: options['caption']?.toString() ?? '', ); } static Future _sendFile( PluginDescriptor plugin, PluginHost host, List args, ) async { _require(plugin, PluginPermission.fileWrite); final options = _mapArg(args, 0); final bytes = await _mediaBytes( plugin, options, maxBytes: 25 * 1024 * 1024, ); await host.sendFile(bytes, filename: _filename(options, 'plugin_file.bin')); } static Future> _fetch( PluginDescriptor plugin, List args, ) async { _require(plugin, PluginPermission.network); final url = _stringArg(args, 0); final options = args.length > 1 && args[1] is Map ? Map.from(args[1] as Map) : const {}; final uri = _httpsUri(url); final method = (options['method']?.toString() ?? 'GET').toUpperCase(); if (!const {'GET', 'POST', 'PUT', 'PATCH', 'DELETE'}.contains(method)) { throw const FormatException('Неподдерживаемый HTTP-метод'); } final client = HttpClient() ..connectionTimeout = const Duration(seconds: 10); try { final request = await client.openUrl(method, uri); request.followRedirects = false; final rawHeaders = options['headers']; if (rawHeaders is Map) { rawHeaders.forEach((key, value) { final name = key.toString(); if (_forbiddenHeader(name)) return; request.headers.set(name, value.toString()); }); } final body = options['body']; if (body != null) request.write(body.toString()); final response = await request.close().timeout( const Duration(seconds: 15), ); if (response.isRedirect) { await response.drain(); throw const HttpException('HTTP redirects are disabled for plugins'); } final bytes = await _readLimited(response, 1024 * 1024); final headers = {}; response.headers.forEach((name, values) { headers[name] = values.join(', '); }); return { 'status': response.statusCode, 'headers': headers, 'body': utf8.decode(bytes, allowMalformed: true), 'base64': base64Encode(bytes), }; } finally { client.close(force: true); } } static Future _mediaBytes( PluginDescriptor plugin, Map options, { required int maxBytes, }) async { final encoded = options['base64']; if (encoded is String && encoded.isNotEmpty) { final bytes = base64Decode(encoded); if (bytes.length > maxBytes) { throw const FormatException('Файл слишком большой'); } return bytes; } final url = options['url']; if (url is! String || url.isEmpty) { throw const FormatException('Нужно указать url или base64'); } _require(plugin, PluginPermission.network); return PluginMediaDownloader.download(_httpsUri(url), maxBytes: maxBytes); } static Future _readLimited( HttpClientResponse response, int maxBytes, ) async { final bytes = []; await for (final chunk in response.timeout(const Duration(seconds: 20))) { bytes.addAll(chunk); if (bytes.length > maxBytes) { throw const FormatException('Ответ слишком большой'); } } return Uint8List.fromList(bytes); } static Uri _httpsUri(String source) { final uri = Uri.tryParse(source); if (uri == null || uri.scheme != 'https' || uri.host.isEmpty) { throw const FormatException('Разрешены только корректные HTTPS URL'); } final host = uri.host.toLowerCase(); if (host == 'localhost' || host.endsWith('.localhost')) { throw const FormatException('Локальные адреса запрещены'); } final address = InternetAddress.tryParse(host); if (address != null && _isPrivateAddress(address)) { throw const FormatException('Локальные адреса запрещены'); } return uri; } static bool _isPrivateAddress(InternetAddress address) { if (address.isLoopback || address.isLinkLocal) return true; final bytes = address.rawAddress; if (address.type == InternetAddressType.IPv4) { return bytes[0] == 10 || bytes[0] == 127 || bytes[0] == 0 || (bytes[0] == 169 && bytes[1] == 254) || (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) || (bytes[0] == 192 && bytes[1] == 168); } return bytes.every((byte) => byte == 0) || (bytes[0] & 0xfe) == 0xfc || (bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80); } static bool _forbiddenHeader(String name) { final lower = name.toLowerCase(); return lower == 'host' || lower == 'content-length' || lower == 'connection' || lower == 'proxy-authorization'; } static Map _mapArg(List args, int index) { if (index >= args.length || args[index] is! Map) { throw const FormatException('Ожидался объект'); } return Map.from(args[index] as Map); } static String _filename(Map options, String fallback) { final raw = options['filename']?.toString().trim() ?? ''; final value = raw.isEmpty ? fallback : raw; return value .replaceAll(RegExp(r'[^A-Za-zА-Яа-яЁё0-9._ -]'), '_') .substring(0, value.length > 120 ? 120 : value.length); } static Future?> _getPeer( PluginDescriptor plugin, PluginHost host, ) { _require(plugin, PluginPermission.contactRead); return host.getPeer(); } static Future _sleep(List args) async { final value = args.isEmpty ? null : args.first; if (value is! num || value < 0 || value > 10000) { throw const FormatException('Некорректная задержка'); } await Future.delayed(Duration(milliseconds: value.round())); } static Future _storageGet( PluginDescriptor plugin, PluginStorage storage, List args, ) { _require(plugin, PluginPermission.storage); return storage.get(_storageKey(args)); } static Future _storageSet( PluginDescriptor plugin, PluginStorage storage, List args, ) { _require(plugin, PluginPermission.storage); if (args.length < 2) throw const FormatException('Не задано значение'); return storage.set(_storageKey(args), args[1]); } static Future _storageRemove( PluginDescriptor plugin, PluginStorage storage, List args, ) { _require(plugin, PluginPermission.storage); return storage.remove(_storageKey(args)); } static void _require(PluginDescriptor plugin, PluginPermission permission) { if (!plugin.grantedPermissions.contains(permission)) { throw FormatException('Нет разрешения ${permission.id}'); } } static String _stringArg(List args, int index) { if (index >= args.length || args[index] is! String) { throw const FormatException('Ожидалась строка'); } final value = args[index] as String; if (value.length > 65536) { throw const FormatException('Строка слишком длинная'); } return value; } static String _storageKey(List args) { final key = _stringArg(args, 0); if (!RegExp(r'^[A-Za-z0-9._-]{1,64}$').hasMatch(key)) { throw const FormatException('Некорректный ключ хранилища'); } return key; } static String _rewriteImports( String source, String prefix, String modulePath, ) { return source.replaceAllMapped( RegExp(r'''(from\s+|import\s+|import\s*\()(['"])(\.?\.?/[^'"]+)\2'''), (match) { final relative = match.group(3)!; final resolved = p.posix.normalize( p.posix.join(p.posix.dirname(modulePath), relative), ); if (resolved == '..' || resolved.startsWith('../')) { throw const FormatException('Импорт за пределы плагина запрещён'); } return '${match.group(1)}${match.group(2)}$prefix$resolved${match.group(2)}'; }, ); } static const String _apiModule = ''' const call = async (method, args = []) => { const response = await fjs.bridge_call({ method, args }); if (!response.ok) throw new Error(response.error || 'ИКАР API error'); return response.value; }; export const chat = Object.freeze({ sendText: text => call('chat.sendText', [text]), editText: (messageId, text) => call('chat.editText', [messageId, text]), sendPhoto: options => call('chat.sendPhoto', [options]), sendFile: options => call('chat.sendFile', [options]) }); export const ui = Object.freeze({ notify: message => call('ui.notify', [message]) }); export const contact = Object.freeze({ getPeer: () => call('contact.getPeer') }); export const runtime = Object.freeze({ sleep: milliseconds => call('runtime.sleep', [milliseconds]), isOnline: () => call('runtime.isOnline'), isActive: () => call('runtime.isActive') }); export const network = Object.freeze({ fetch: (url, options = {}) => call('network.fetch', [url, options]) }); export const storage = Object.freeze({ get: key => call('storage.get', [key]), set: (key, value) => call('storage.set', [key, value]), remove: key => call('storage.remove', [key]) }); '''; }