300 lines
10 KiB
Dart
300 lines
10 KiB
Dart
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 = <String>[
|
|
'assets/plugins/info',
|
|
'assets/plugins/nekos',
|
|
'assets/plugins/weather',
|
|
];
|
|
|
|
final ValueNotifier<List<PluginDescriptor>> plugins = ValueNotifier(const []);
|
|
Directory? _root;
|
|
Map<String, dynamic> _state = {};
|
|
|
|
static bool isBundledId(String id) => id.startsWith('pw.qlyra.');
|
|
|
|
Future<void> 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<String, dynamic>.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<void> _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<void> refresh() async {
|
|
final descriptors = <PluginDescriptor>[];
|
|
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<PluginDescriptor> _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<PluginDescriptor> _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 = <PluginPermission>{};
|
|
final rawGranted = state['granted'];
|
|
if (rawGranted is List) {
|
|
for (final raw in rawGranted.whereType<String>()) {
|
|
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<Map<String, String>> _loadInstalledModules(Directory directory) async {
|
|
final modules = <String, String>{};
|
|
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<Map<String, Uint8List>> _loadInstalledBytes(
|
|
Directory directory,
|
|
) async {
|
|
final files = <String, Uint8List>{};
|
|
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<PluginDescriptor> install(
|
|
List<int> bytes, {
|
|
required Set<PluginPermission> 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<void> setEnabled(String pluginId, bool enabled) async {
|
|
final state = _pluginState(pluginId);
|
|
state['enabled'] = enabled;
|
|
_state[pluginId] = state;
|
|
await _persistState();
|
|
await refresh();
|
|
}
|
|
|
|
Future<void> 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<String, dynamic> _pluginState(String pluginId) {
|
|
final raw = _state[pluginId];
|
|
return raw is Map ? Map<String, dynamic>.from(raw) : <String, dynamic>{};
|
|
}
|
|
|
|
Future<void> _persistState() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_stateKey, jsonEncode(_state));
|
|
}
|
|
}
|