import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../storage/app_instance.dart'; import 'plugin_manifest.dart'; import 'plugin_models.dart'; import 'plugin_package.dart'; import 'plugin_signing.dart'; import 'plugin_storage.dart'; class PluginStore { PluginStore._(); static final PluginStore instance = PluginStore._(); static const _stateKey = 'plugins_state_v1'; static const _bundled = [ 'assets/plugins/info', 'assets/plugins/nekos', 'assets/plugins/weather', ]; final ValueNotifier> plugins = ValueNotifier(const []); Directory? _root; Map _state = {}; static bool isBundledId(String id) => id.startsWith('pw.qlyra.'); Future load() async { final prefs = await SharedPreferences.getInstance(); final rawState = prefs.getString(_stateKey); if (rawState != null) { try { final decoded = jsonDecode(rawState); if (decoded is Map) _state = Map.from(decoded); } catch (_) {} } final base = await getApplicationSupportDirectory(); _root = Directory(p.join(base.path, 'plugins${AppInstance.suffix}')); await _root!.create(recursive: true); await _recoverInterruptedInstalls(); await refresh(); } Future _recoverInterruptedInstalls() async { final root = _root; if (root == null) return; await for (final entity in root.list()) { if (entity is! Directory) continue; final name = p.basename(entity.path); if (!name.startsWith('.')) continue; if (name.endsWith('.backup')) { final pluginId = name.substring(1, name.length - '.backup'.length); final target = Directory(p.join(root.path, pluginId)); if (await target.exists()) { await entity.delete(recursive: true); } else { await entity.rename(target.path); } } else { await entity.delete(recursive: true); } } } Future refresh() async { final descriptors = []; for (final assetPath in _bundled) { try { descriptors.add(await _loadBundled(assetPath)); } catch (_) {} } final root = _root; if (root != null && await root.exists()) { await for (final entity in root.list()) { if (entity is! Directory || p.basename(entity.path).startsWith('.')) { continue; } try { descriptors.add(await _loadInstalled(entity)); } catch (_) {} } } descriptors.sort((a, b) { final origin = a.origin.index.compareTo(b.origin.index); return origin != 0 ? origin : a.manifest.name.compareTo(b.manifest.name); }); plugins.value = List.unmodifiable(descriptors); } Future _loadBundled(String assetPath) async { final manifest = PluginManifest.decode( await rootBundle.loadString('$assetPath/manifest.json'), ); return PluginDescriptor( manifest: manifest, origin: PluginOrigin.bundled, enabled: _enabled(manifest.id, fallback: true), grantedPermissions: manifest.permissions, loadModules: () async => { manifest.main: await rootBundle.loadString( '$assetPath/${manifest.main}', ), }, signatureStatus: PluginSignatureStatus.bundled, signerFingerprint: 'KOMET:BUNDLED', ); } Future _loadInstalled(Directory directory) async { final manifestFile = File(p.join(directory.path, 'manifest.json')); final manifestBytes = await manifestFile.readAsBytes(); final manifest = PluginManifest.decode(utf8.decode(manifestBytes)); final state = _pluginState(manifest.id); var signerFingerprint = state['signerFingerprint']?.toString(); if (manifest.signature != null) { final files = await _loadInstalledBytes(directory); files['manifest.json'] = manifestBytes; final verification = await PluginSigning.verify(manifest, files); if (signerFingerprint != null && verification.fingerprint != signerFingerprint) { throw const FormatException( 'Ключ подписи установленного плагина изменён', ); } signerFingerprint = verification.fingerprint; } else if (signerFingerprint != null) { throw const FormatException('Подпись установленного плагина удалена'); } final granted = {}; final rawGranted = state['granted']; if (rawGranted is List) { for (final raw in rawGranted.whereType()) { final permission = PluginPermission.fromId(raw); if (permission != null && manifest.permissions.contains(permission)) { granted.add(permission); } } } return PluginDescriptor( manifest: manifest, origin: PluginOrigin.installed, enabled: _enabled(manifest.id, fallback: true), grantedPermissions: Set.unmodifiable(granted), loadModules: () => _loadInstalledModules(directory), signatureStatus: signerFingerprint == null ? PluginSignatureStatus.unsigned : PluginSignatureStatus.verified, signerFingerprint: signerFingerprint, ); } Future> _loadInstalledModules(Directory directory) async { final modules = {}; await for (final entity in directory.list(recursive: true)) { if (entity is! File || !entity.path.endsWith('.js')) continue; final relative = p .relative(entity.path, from: directory.path) .replaceAll('\\', '/'); modules[relative] = await entity.readAsString(); } return modules; } Future> _loadInstalledBytes( Directory directory, ) async { final files = {}; await for (final entity in directory.list(recursive: true)) { if (entity is! File || !entity.path.endsWith('.js')) continue; final relative = p .relative(entity.path, from: directory.path) .replaceAll('\\', '/'); files[relative] = await entity.readAsBytes(); } return files; } Future install( List bytes, { required Set grantedPermissions, Uri? sourceUrl, }) async { final package = PluginPackage.decode(bytes); final verification = await PluginSigning.verify( package.manifest, package.files, ); if (isBundledId(package.manifest.id)) { throw const FormatException('Этот id зарезервирован ИКАР'); } if (!package.manifest.permissions.containsAll(grantedPermissions)) { throw const FormatException('Выданы неизвестные разрешения'); } final previousState = _pluginState(package.manifest.id); final previousPublicKey = previousState['signerPublicKey']?.toString(); final signerPublicKey = package.manifest.signature?.publicKey; if (previousPublicKey != null && signerPublicKey != previousPublicKey) { throw const FormatException( 'Обновление должно быть подписано прежним ключом автора', ); } final root = _root; if (root == null) throw StateError('PluginStore не инициализирован'); final target = Directory(p.join(root.path, package.manifest.id)); final staging = Directory( p.join( root.path, '.${package.manifest.id}.${DateTime.now().microsecondsSinceEpoch}', ), ); final backup = Directory( p.join(root.path, '.${package.manifest.id}.backup'), ); await staging.create(recursive: true); try { for (final entry in package.files.entries) { final file = File(p.join(staging.path, entry.key)); await file.parent.create(recursive: true); await file.writeAsBytes(entry.value, flush: true); } if (await backup.exists()) await backup.delete(recursive: true); if (await target.exists()) await target.rename(backup.path); try { await staging.rename(target.path); } catch (_) { if (await backup.exists()) await backup.rename(target.path); rethrow; } if (await backup.exists()) await backup.delete(recursive: true); _state[package.manifest.id] = { 'enabled': true, 'granted': grantedPermissions .map((permission) => permission.id) .toList(), if (sourceUrl != null) 'sourceUrl': sourceUrl.toString(), 'signerFingerprint': ?verification.fingerprint, 'signerPublicKey': ?signerPublicKey, }; await _persistState(); await refresh(); return plugins.value.firstWhere( (plugin) => plugin.manifest.id == package.manifest.id, ); } finally { if (await staging.exists()) await staging.delete(recursive: true); } } Future setEnabled(String pluginId, bool enabled) async { final state = _pluginState(pluginId); state['enabled'] = enabled; _state[pluginId] = state; await _persistState(); await refresh(); } Future uninstall(String pluginId) async { final plugin = plugins.value.firstWhere( (item) => item.manifest.id == pluginId, ); if (plugin.origin == PluginOrigin.bundled) { throw StateError('Встроенный плагин нельзя удалить'); } final root = _root; if (root != null) { final directory = Directory(p.join(root.path, pluginId)); if (await directory.exists()) await directory.delete(recursive: true); } _state.remove(pluginId); await PluginStorage(pluginId).clear(); await _persistState(); await refresh(); } Uri? sourceUrl(String pluginId) { final raw = _pluginState(pluginId)['sourceUrl']; return raw is String ? Uri.tryParse(raw) : null; } bool _enabled(String pluginId, {required bool fallback}) { final value = _pluginState(pluginId)['enabled']; return value is bool ? value : fallback; } Map _pluginState(String pluginId) { final raw = _state[pluginId]; return raw is Map ? Map.from(raw) : {}; } Future _persistState() async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_stateKey, jsonEncode(_state)); } }