diff --git a/core/bin/openrom.dart b/core/bin/openrom.dart index 421634b..5fb34aa 100644 --- a/core/bin/openrom.dart +++ b/core/bin/openrom.dart @@ -71,6 +71,11 @@ ArgParser buildParser() { 'rename-roms', help: 'rename ROMs in a folder to canonical DAT names', ) + // Plugin Management + ..addOption('install-plugin', help: 'install a plugin from a .orplug file') + ..addFlag('list-plugins', + negatable: false, help: 'list all installed plugins') + ..addOption('remove-plugin', help: 'uninstall a plugin by ID') // Dreamcast DCP patch ..addOption( 'dcp', @@ -191,6 +196,9 @@ Future main(List args) async { return 0; } + // Auto-load installed plugins + await PluginManager.instance.loadInstalledPlugins(); + final parser = buildParser(); final ArgResults results; try { @@ -212,6 +220,57 @@ Future main(List args) async { final isJson = results['json'] == true; + if (results['install-plugin'] != null) { + final pluginPath = results['install-plugin'] as String; + final ok = await PluginManager.instance.installPlugin(pluginPath); + if (isJson) { + _jsonPrint({'type': 'done', 'success': ok}); + } else { + print(ok + ? '✅ Plugin installed successfully.' + : '❌ Failed to install plugin.'); + } + return ok ? 0 : 1; + } + + if (results['list-plugins'] == true) { + final plugins = PluginManager.instance.installedPlugins; + if (isJson) { + _jsonPrint({ + 'type': 'done', + 'success': true, + 'plugins': plugins.map((p) => p.toJson()).toList(), + }); + } else { + if (plugins.isEmpty) { + print('No plugins installed.'); + } else { + print('Installed Plugins:'); + for (final plug in plugins) { + print( + ' - ${plug.name} (${plug.id}) v${plug.version} by ${plug.author}'); + if (plug.description.isNotEmpty) { + print(' ${plug.description}'); + } + } + } + } + return 0; + } + + if (results['remove-plugin'] != null) { + final pluginId = results['remove-plugin'] as String; + final ok = await PluginManager.instance.uninstallPlugin(pluginId); + if (isJson) { + _jsonPrint({'type': 'done', 'success': ok}); + } else { + print(ok + ? '✅ Plugin "$pluginId" removed.' + : '❌ Failed to remove plugin "$pluginId".'); + } + return ok ? 0 : 1; + } + if (results['tool-path'] != null) { final pStr = getToolPath(results['tool-path'] as String); if (isJson) { diff --git a/core/lib/openrom_core.dart b/core/lib/openrom_core.dart index 41ac2d8..7fb2c66 100644 --- a/core/lib/openrom_core.dart +++ b/core/lib/openrom_core.dart @@ -20,3 +20,6 @@ export 'src/logger.dart'; export 'src/online_prefs.dart'; export 'src/version_reader.dart'; export 'src/one_g_one_r.dart'; +export 'src/plugin/plugin_manifest.dart'; +export 'src/plugin/plugin_registry.dart'; +export 'src/plugin/plugin_manager.dart'; diff --git a/core/lib/src/converter.dart b/core/lib/src/converter.dart index 5dd03ec..3187234 100644 --- a/core/lib/src/converter.dart +++ b/core/lib/src/converter.dart @@ -10,6 +10,8 @@ import 'config.dart' as config; import 'cue_generator.dart'; import 'detector.dart' as detector; import 'logger.dart' as logger; +import 'plugin/plugin_manager.dart'; +import 'plugin/plugin_registry.dart'; import 'validator.dart'; const List cdPlatforms = [ @@ -179,7 +181,7 @@ class Converter { } catch (e) { job.error = e.toString(); job.status = 'Failed'; - _log('[ERROR] '); + _log('[ERROR] $e'); return false; } finally { _cleanup(job); @@ -206,15 +208,21 @@ class Converter { final fmt = info['format'] as String? ?? 'UNKNOWN'; final tgt = job.targetFormat.toUpperCase(); + // Check for plugin-registered conversion route first + final pluginConv = PluginRegistry.instance.getConversion(fmt, tgt); + if (pluginConv != null) { + return await _pluginConvert(job, src, pluginConv); + } + if (fmt == 'ECM') { return await _fromEcm(job, src, tgt); } final valid = detector.getValidTargets(fmt); if (!valid.contains(tgt) && tgt != 'BIN/CUE' && tgt != 'FILES') { - final err = 'Cannot convert → . ' - 'Supported targets for : ${valid.isNotEmpty ? valid.join(", ") : "none"}'; - _log('[ERROR] '); + final err = 'Cannot convert $fmt → $tgt. ' + 'Supported targets for $fmt: ${valid.isNotEmpty ? valid.join(", ") : "none"}'; + _log('[ERROR] $err'); job.error = err; return false; } @@ -265,12 +273,51 @@ class Converter { return await _xboxConvert(job, src, fmt); } - final err = 'Unhandled conversion route: → '; - _log('[ERROR] '); + final err = 'Unhandled conversion route: $fmt → $tgt'; + _log('[ERROR] $err'); job.error = err; return false; } + Future _pluginConvert( + ConversionJob job, + String src, + RegisteredConversion conv, + ) async { + final pluginsDir = PluginManager.instance.pluginsDir; + final relBin = conv.executableRelPath ?? ''; + final absBin = + relBin.isNotEmpty ? p.join(pluginsDir, conv.pluginId, relBin) : ''; + + if (relBin.isNotEmpty && !File(absBin).existsSync()) { + final err = + 'Plugin executable not found for plugin "${conv.pluginId}": $absBin'; + job.error = err; + _log(' ❌ $err'); + return false; + } + + final outName = + detector.getOutputName(p.basename(src), conv.to.toLowerCase()); + final outPath = p.join(job.outputDir, outName); + + final idx = src.lastIndexOf('.'); + final base = idx != -1 ? src.substring(0, idx) : src; + final cueName = '$base.cue'; + + final cmdLine = conv.commandTemplate + .replaceAll('{bin}', absBin) + .replaceAll('{in}', src) + .replaceAll('{out}', outPath) + .replaceAll('{cue}', cueName) + .replaceAll('{out_cue}', cueName); + + final cmdArgs = splitCommandArgs(cmdLine); + _log( + '[PLUGIN ${conv.pluginId}] ${p.basename(src)} → ${p.basename(outPath)}'); + return await _run(cmdArgs, job); + } + Future _toChd( ConversionJob job, String src, @@ -306,7 +353,7 @@ class Converter { } else { final err = "Unsupported or ambiguous media type for platform '$platform'. Please specify --media cd or --media dvd."; - _log('[ERROR] '); + _log('[ERROR] $err'); job.error = err; return false; } @@ -399,7 +446,7 @@ class Converter { } } - _log('[CHD EXTRACT] ${p.basename(src)} → '); + _log('[CHD EXTRACT] ${p.basename(src)} → ${p.basename(cmd.last)}'); return await _run(cmd, job); } @@ -452,7 +499,7 @@ class Converter { File(out).renameSync(newOut); _log('[UNECM AUTO-DETECT] Renamed output to: ${p.basename(newOut)}'); } catch (e) { - _log('[WARN] Failed to rename to : '); + _log('[WARN] Failed to rename $out to $newOut: $e'); } } } @@ -472,7 +519,7 @@ class Converter { final outDir = p.join(job.outputDir, '${base}_extracted'); Directory(outDir).createSync(recursive: true); final cmd = [xiso, '-x', src, '-d', outDir]; - _log('[XISO→FILES] Extracting ${p.basename(src)} → /'); + _log('[XISO→FILES] Extracting ${p.basename(src)} → $outDir'); _log( '[XISO→FILES] Output is a folder of extracted files, not a single ISO.', ); @@ -530,7 +577,7 @@ class Converter { 'Tool not found: "$toolName" is missing from the assets folder. ' 'Please reinstall OpenROM or set the tool path manually in Settings.'; job.error = err; - _log(' ❌ '); + _log(' ❌ $err'); return false; } @@ -551,7 +598,7 @@ class Converter { final trimmed = line.trimRight(); if (trimmed.isNotEmpty) { lastLine = trimmed; - _log(' '); + _log(' $trimmed'); if (isStderr) { if (stderrBuffer.isNotEmpty) stderrBuffer.writeln(); stderrBuffer.write(trimmed); diff --git a/core/lib/src/detector.dart b/core/lib/src/detector.dart index 11c00f3..4eb2050 100644 --- a/core/lib/src/detector.dart +++ b/core/lib/src/detector.dart @@ -7,8 +7,9 @@ import 'dart:typed_data'; import 'package:path/path.dart' as p; import 'config.dart' as config; +import 'plugin/plugin_registry.dart'; -const Map supportedInput = { +const Map builtinSupportedInput = { '.iso': 'ISO', '.bin': 'BIN', '.cue': 'CUE', @@ -31,7 +32,7 @@ const Map supportedInput = { '.zar': 'ZAR', }; -const Map platformMap = { +const Map builtinPlatformMap = { 'ISO': 'ISO Image', 'BIN': 'CD Image', 'CUE': 'CD Cue Sheet', @@ -59,7 +60,7 @@ const Map platformMap = { 'Neo Geo CD': 'Neo Geo CD', }; -const Map formatColors = { +const Map builtinFormatColors = { 'ISO': '#e94560', 'BIN': '#f9a825', 'CUE': '#f9a825', @@ -88,7 +89,7 @@ const Map formatColors = { 'Neo Geo CD': '#b71c1c', }; -const Map> conversionMap = { +const Map> builtinConversionMap = { 'ISO': ['CHD', 'CSO', 'ECM', 'XISO', 'RVZ', 'NKIT'], 'BIN': ['CHD', 'ECM'], 'CUE': ['CHD'], @@ -112,7 +113,7 @@ const Map> conversionMap = { 'ZAR': ['ISO'], }; -const Map commandTemplates = { +const Map builtinCommandTemplates = { 'ISO->CHD': 'chdman createdvd -i "{in}" -o "{out}"', 'ISO->CSO': 'maxcso "{in}" -o "{out}"', 'ISO->ECM': 'ecm "{in}" "{out}"', @@ -148,6 +149,50 @@ const Map commandTemplates = { 'ZAR->ISO': 'xgdtool --xiso "{in}" "{outdir}"', }; +// Merged dynamic getters +Map get supportedInput { + final map = Map.from(builtinSupportedInput); + map.addAll(PluginRegistry.instance.supportedInput); + return map; +} + +Map get platformMap { + final map = Map.from(builtinPlatformMap); + map.addAll(PluginRegistry.instance.platformMap); + return map; +} + +Map get formatColors { + final map = Map.from(builtinFormatColors); + map.addAll(PluginRegistry.instance.formatColors); + return map; +} + +Map> get conversionMap { + final map = Map>.from( + builtinConversionMap.map((k, v) => MapEntry(k, List.from(v))), + ); + final pluginConvs = PluginRegistry.instance.conversionMap; + pluginConvs.forEach((from, targets) { + final list = map.putIfAbsent(from, () => []); + for (final t in targets) { + if (!list.contains(t)) list.add(t); + } + }); + return map; +} + +Map get commandTemplates { + final map = Map.from(builtinCommandTemplates); + for (final plugin in PluginRegistry.instance.activePlugins) { + for (final conv in plugin.conversions) { + final routeKey = '${conv.from.toUpperCase()}->${conv.to.toUpperCase()}'; + map[routeKey] = conv.command; + } + } + return map; +} + // Magic bytes final Uint8List chdMagic = Uint8List.fromList([ 0x4D, @@ -181,9 +226,17 @@ const int xboxOffset2 = 0x2090000; String getExtension(String filename) { final nameLower = filename.toLowerCase(); - if (nameLower.endsWith('.nkit.iso')) { - return 'nkit.iso'; + + // Check known longer extensions first (both built-in and plugin registered) + final allKnownExts = supportedInput.keys.toList() + ..sort((a, b) => b.length.compareTo(a.length)); + + for (final extKey in allKnownExts) { + if (nameLower.endsWith(extKey)) { + return extKey.startsWith('.') ? extKey.substring(1) : extKey; + } } + final parts = nameLower.split('.'); return parts.length > 1 ? parts.last : ''; } @@ -191,8 +244,11 @@ String getExtension(String filename) { String getOutputName(String inputFile, String newExt) { final cleanExt = newExt.startsWith('.') ? newExt.substring(1) : newExt; final String base; - if (inputFile.toLowerCase().endsWith('.nkit.iso')) { - base = inputFile.substring(0, inputFile.length - 9); + final currentExt = getExtension(inputFile); + final extWithDot = '.$currentExt'; + + if (inputFile.toLowerCase().endsWith(extWithDot.toLowerCase())) { + base = inputFile.substring(0, inputFile.length - extWithDot.length); } else { final idx = inputFile.lastIndexOf('.'); base = idx != -1 ? inputFile.substring(0, idx) : inputFile; @@ -243,13 +299,8 @@ Map detectFile(String filepath) { } final name = p.basename(filepath); - final nameLower = name.toLowerCase(); - final String ext; - if (nameLower.endsWith('.nkit.iso')) { - ext = '.nkit.iso'; - } else { - ext = p.extension(nameLower); - } + final rawExt = getExtension(name); + final ext = rawExt.startsWith('.') ? rawExt : '.$rawExt'; final size = file.lengthSync(); final fmt = supportedInput[ext] ?? 'UNKNOWN'; @@ -290,7 +341,8 @@ List> detectFolder(String folderPath) { for (final entry in entries) { if (entry is File) { final fname = p.basename(entry.path); - final ext = '.${getExtension(fname)}'; + final rawExt = getExtension(fname); + final ext = rawExt.startsWith('.') ? rawExt : '.$rawExt'; if (supportedInput.containsKey(ext)) { final info = detectFile(entry.path); info['filepath'] = entry.path; diff --git a/core/lib/src/plugin/plugin_manager.dart b/core/lib/src/plugin/plugin_manager.dart new file mode 100644 index 0000000..4a0e007 --- /dev/null +++ b/core/lib/src/plugin/plugin_manager.dart @@ -0,0 +1,262 @@ +// OpenROM — Universal ROM Compression Suite +// M5 Dev | GPL v3 + +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; + +import '../logger.dart' as logger; +import 'plugin_manifest.dart'; +import 'plugin_registry.dart'; + +class PluginManager { + static final PluginManager instance = PluginManager._internal(); + + PluginManager._internal(); + + String? _customPluginsDir; + + void setCustomPluginsDir(String? dirPath) { + _customPluginsDir = dirPath; + } + + /// Resolves the user storage directory for plugins: + /// - Windows: %APPDATA%\OpenROM\plugins\ + /// - Linux/macOS: ~/.local/share/openrom/plugins/ (fallback ~/.openrom/plugins/) + String get pluginsDir { + if (_customPluginsDir != null && _customPluginsDir!.isNotEmpty) { + return _customPluginsDir!; + } + + if (Platform.isWindows) { + final appData = Platform.environment['APPDATA']; + if (appData != null && appData.isNotEmpty) { + return p.join(appData, 'OpenROM', 'plugins'); + } + final userProfile = Platform.environment['USERPROFILE']; + if (userProfile != null && userProfile.isNotEmpty) { + return p.join(userProfile, 'AppData', 'Roaming', 'OpenROM', 'plugins'); + } + } else { + final home = Platform.environment['HOME'] ?? + Platform.environment['USERPROFILE'] ?? + ''; + if (home.isNotEmpty) { + final localShare = + p.join(home, '.local', 'share', 'openrom', 'plugins'); + final localShareParent = p.join(home, '.local', 'share'); + if (Directory(localShareParent).existsSync() || + Directory(localShare).existsSync()) { + return localShare; + } + return p.join(home, '.openrom', 'plugins'); + } + } + + return p.join(Directory.current.path, 'plugins'); + } + + /// Scans the plugins directory, loads each subfolder containing `plugin.json`, + /// validates the schema, and registers active plugins in `PluginRegistry`. + Future loadInstalledPlugins() async { + PluginRegistry.instance.clear(); + final pDir = Directory(pluginsDir); + if (!pDir.existsSync()) { + return; + } + + final entries = pDir.listSync(); + for (final entry in entries) { + if (entry is Directory) { + final manifestFile = File(p.join(entry.path, 'plugin.json')); + if (manifestFile.existsSync()) { + try { + final jsonStr = manifestFile.readAsStringSync(); + final jsonMap = jsonDecode(jsonStr) as Map; + final manifest = PluginManifest.fromJson(jsonMap); + final errors = manifest.validate(); + if (errors.isEmpty) { + PluginRegistry.instance.registerPlugin(manifest); + logger.log( + '[PLUGIN] Loaded plugin: ${manifest.name} (${manifest.id}) v${manifest.version}'); + } else { + logger.log( + '[PLUGIN WARN] Invalid plugin manifest in ${entry.path}: ${errors.join(", ")}'); + } + } catch (e) { + logger.log( + '[PLUGIN ERROR] Failed to load plugin in ${entry.path}: $e'); + } + } + } + } + } + + /// Installs a plugin from a `.orplug` archive file. + /// 1. Extracts the ZIP archive into `//`. + /// 2. Validates `plugin.json`. + /// 3. Verifies the SHA256 of the current platform binary (if included/specified). + /// 4. Sets executable permission on non-Windows platforms. + /// 5. Refuses installation if corrupted or manifest is invalid. + Future installPlugin(String orplugFilePath) async { + final file = File(orplugFilePath); + if (!file.existsSync()) { + logger + .log('[PLUGIN ERROR] Plugin archive file not found: $orplugFilePath'); + return false; + } + + final tempExtractDir = + Directory.systemTemp.createTempSync('openrom_plugin_extract_'); + try { + final bytes = file.readAsBytesSync(); + final archive = ZipDecoder().decodeBytes(bytes); + + final basePath = p.normalize(p.canonicalize(tempExtractDir.path)); + + for (final archiveFile in archive) { + final destPath = p.normalize(p.join(basePath, archiveFile.name)); + if (!destPath.startsWith(basePath + p.separator) && + destPath != basePath) { + logger.log( + '[PLUGIN ERROR] Security error: Malicious zip entry in archive: ${archiveFile.name}'); + return false; + } + + if (archiveFile.isFile) { + final data = archiveFile.content as List; + File(destPath) + ..createSync(recursive: true) + ..writeAsBytesSync(data); + } else { + Directory(destPath).createSync(recursive: true); + } + } + + final manifestFile = File(p.join(tempExtractDir.path, 'plugin.json')); + if (!manifestFile.existsSync()) { + logger.log( + '[PLUGIN ERROR] Archive does not contain a valid "plugin.json" manifest.'); + return false; + } + + final jsonStr = manifestFile.readAsStringSync(); + final jsonMap = jsonDecode(jsonStr) as Map; + final manifest = PluginManifest.fromJson(jsonMap); + + final errors = manifest.validate(); + if (errors.isNotEmpty) { + logger.log( + '[PLUGIN ERROR] Manifest validation failed: ${errors.join("; ")}'); + return false; + } + + // Verify SHA256 of current platform binary if specified + final platformBin = manifest.currentPlatformBinary; + if (platformBin != null && platformBin.executable.isNotEmpty) { + final binFile = + File(p.join(tempExtractDir.path, platformBin.executable)); + if (!binFile.existsSync()) { + logger.log( + '[PLUGIN ERROR] Missing expected platform executable: ${platformBin.executable}'); + return false; + } + + if (platformBin.sha256.isNotEmpty) { + final binBytes = binFile.readAsBytesSync(); + final computedDigest = + sha256.convert(binBytes).toString().toLowerCase(); + final expectedDigest = platformBin.sha256.trim().toLowerCase(); + + if (computedDigest != expectedDigest) { + logger.log( + '[PLUGIN ERROR] SHA256 mismatch for ${platformBin.executable}. ' + 'Expected: $expectedDigest, Got: $computedDigest', + ); + return false; + } + } + + // Ensure executable permissions on Unix systems + if (!Platform.isWindows) { + try { + Process.runSync('chmod', ['+x', binFile.path]); + } catch (_) {} + } + } + + final targetPluginDir = Directory(p.join(pluginsDir, manifest.id)); + if (targetPluginDir.existsSync()) { + targetPluginDir.deleteSync(recursive: true); + } + targetPluginDir.createSync(recursive: true); + + _copyDirectorySync(tempExtractDir, targetPluginDir); + + // Reload installed plugins into registry + await loadInstalledPlugins(); + logger.log( + '[PLUGIN] Successfully installed plugin "${manifest.name}" (${manifest.id})'); + return true; + } catch (e) { + logger.log('[PLUGIN ERROR] Plugin installation failed: $e'); + return false; + } finally { + try { + if (tempExtractDir.existsSync()) { + tempExtractDir.deleteSync(recursive: true); + } + } catch (_) {} + } + } + + /// Safely removes the plugin directory and unregisters it. + Future uninstallPlugin(String pluginId) async { + final targetPluginDir = Directory(p.join(pluginsDir, pluginId)); + if (!targetPluginDir.existsSync()) { + logger.log( + '[PLUGIN WARN] Cannot uninstall: Plugin directory not found for ID "$pluginId"'); + return false; + } + + try { + targetPluginDir.deleteSync(recursive: true); + PluginRegistry.instance.unregisterPlugin(pluginId); + logger.log('[PLUGIN] Successfully uninstalled plugin "$pluginId"'); + return true; + } catch (e) { + logger.log('[PLUGIN ERROR] Failed to uninstall plugin "$pluginId": $e'); + return false; + } + } + + /// Exposes currently installed/active plugins from registry. + List get installedPlugins => + PluginRegistry.instance.activePlugins; + + void _copyDirectorySync(Directory source, Directory destination) { + for (final entity in source.listSync(recursive: false)) { + final name = p.basename(entity.path); + final destPath = p.join(destination.path, name); + if (entity is Directory) { + final newDir = Directory(destPath)..createSync(recursive: true); + _copyDirectorySync(entity, newDir); + } else if (entity is File) { + entity.copySync(destPath); + if (!Platform.isWindows) { + try { + final stat = entity.statSync(); + final isExecutable = (stat.mode & 0x49) != + 0; // Check executable bits (S_IXUSR, S_IXGRP, S_IXOTH) + if (isExecutable) { + Process.runSync('chmod', ['+x', destPath]); + } + } catch (_) {} + } + } + } + } +} diff --git a/core/lib/src/plugin/plugin_manifest.dart b/core/lib/src/plugin/plugin_manifest.dart new file mode 100644 index 0000000..197a3eb --- /dev/null +++ b/core/lib/src/plugin/plugin_manifest.dart @@ -0,0 +1,230 @@ +// OpenROM — Universal ROM Compression Suite +// M5 Dev | GPL v3 + +import 'dart:ffi'; +import 'dart:io'; + +class PluginBinary { + final String executable; + final String sha256; + + PluginBinary({ + required this.executable, + required this.sha256, + }); + + factory PluginBinary.fromJson(Map json) { + return PluginBinary( + executable: json['executable'] as String? ?? '', + sha256: json['sha256'] as String? ?? '', + ); + } + + Map toJson() { + return { + 'executable': executable, + 'sha256': sha256, + }; + } +} + +class PluginExtension { + final String ext; + final String format; + final String platform; + final String color; + + PluginExtension({ + required this.ext, + required this.format, + required this.platform, + required this.color, + }); + + factory PluginExtension.fromJson(Map json) { + return PluginExtension( + ext: json['ext'] as String? ?? '', + format: json['format'] as String? ?? '', + platform: json['platform'] as String? ?? '', + color: json['color'] as String? ?? '#7a8a9a', + ); + } + + Map toJson() { + return { + 'ext': ext, + 'format': format, + 'platform': platform, + 'color': color, + }; + } +} + +class PluginConversion { + final String from; + final String to; + final String command; + + PluginConversion({ + required this.from, + required this.to, + required this.command, + }); + + factory PluginConversion.fromJson(Map json) { + return PluginConversion( + from: json['from'] as String? ?? '', + to: json['to'] as String? ?? '', + command: json['command'] as String? ?? '', + ); + } + + Map toJson() { + return { + 'from': from, + 'to': to, + 'command': command, + }; + } +} + +class PluginManifest { + final String id; + final String name; + final String version; + final String author; + final String description; + final String minCoreVersion; + final Map binaries; + final List extensions; + final List conversions; + + PluginManifest({ + required this.id, + required this.name, + required this.version, + required this.author, + required this.description, + required this.minCoreVersion, + required this.binaries, + required this.extensions, + required this.conversions, + }); + + factory PluginManifest.fromJson(Map json) { + final rawBinaries = json['binaries'] as Map? ?? {}; + final binaries = rawBinaries.map( + (k, v) => MapEntry( + k, + PluginBinary.fromJson(v as Map? ?? {}), + ), + ); + + final rawExts = json['extensions'] as List? ?? []; + final extensions = rawExts + .map((e) => PluginExtension.fromJson(e as Map? ?? {})) + .toList(); + + final rawConvs = json['conversions'] as List? ?? []; + final conversions = rawConvs + .map((c) => PluginConversion.fromJson(c as Map? ?? {})) + .toList(); + + return PluginManifest( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + version: json['version'] as String? ?? '', + author: json['author'] as String? ?? '', + description: json['description'] as String? ?? '', + minCoreVersion: json['minCoreVersion'] as String? ?? '', + binaries: binaries, + extensions: extensions, + conversions: conversions, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'version': version, + 'author': author, + 'description': description, + 'minCoreVersion': minCoreVersion, + 'binaries': binaries.map((k, v) => MapEntry(k, v.toJson())), + 'extensions': extensions.map((e) => e.toJson()).toList(), + 'conversions': conversions.map((c) => c.toJson()).toList(), + }; + } + + /// Validates that the manifest has required fields and valid schema. + List validate() { + final errors = []; + if (id.trim().isEmpty) { + errors.add('Plugin "id" is required.'); + } else if (!RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(id)) { + errors.add( + 'Plugin "id" must contain only alphanumeric characters, underscores, or hyphens.'); + } + + if (name.trim().isEmpty) { + errors.add('Plugin "name" is required.'); + } + if (version.trim().isEmpty) { + errors.add('Plugin "version" is required.'); + } + if (minCoreVersion.trim().isEmpty) { + errors.add('Plugin "minCoreVersion" is required.'); + } + + for (final ext in extensions) { + if (ext.ext.trim().isEmpty) { + errors.add('Extension "ext" cannot be empty.'); + } + if (ext.format.trim().isEmpty) { + errors.add('Extension "format" cannot be empty.'); + } + } + + for (final conv in conversions) { + if (conv.from.trim().isEmpty || conv.to.trim().isEmpty) { + errors.add('Conversion "from" and "to" cannot be empty.'); + } + if (conv.command.trim().isEmpty) { + errors.add('Conversion "command" cannot be empty.'); + } + } + + return errors; + } + + /// Helper to get the binary details for the current host platform. + PluginBinary? get currentPlatformBinary { + final platformKey = getCurrentPlatformKey(); + return binaries[platformKey]; + } +} + +/// Helper function to resolve host platform key string. +String getCurrentPlatformKey() { + if (Platform.isWindows) { + return 'windows_x64'; + } else if (Platform.isLinux) { + try { + final abiStr = Abi.current().toString().toLowerCase(); + if (abiStr.contains('arm64') || abiStr.contains('aarch64')) { + return 'linux_arm64'; + } + } catch (_) {} + return 'linux_x64'; + } else if (Platform.isMacOS) { + try { + final abiStr = Abi.current().toString().toLowerCase(); + if (abiStr.contains('arm64') || abiStr.contains('aarch64')) { + return 'macos_arm64'; + } + } catch (_) {} + return 'macos_x64'; + } + return ''; +} diff --git a/core/lib/src/plugin/plugin_registry.dart b/core/lib/src/plugin/plugin_registry.dart new file mode 100644 index 0000000..170a122 --- /dev/null +++ b/core/lib/src/plugin/plugin_registry.dart @@ -0,0 +1,113 @@ +// OpenROM — Universal ROM Compression Suite +// M5 Dev | GPL v3 + +import 'plugin_manifest.dart'; + +class RegisteredConversion { + final String pluginId; + final String from; + final String to; + final String commandTemplate; + final String? executableRelPath; + + RegisteredConversion({ + required this.pluginId, + required this.from, + required this.to, + required this.commandTemplate, + this.executableRelPath, + }); +} + +class PluginRegistry { + static final PluginRegistry instance = PluginRegistry._internal(); + + PluginRegistry._internal(); + + final List _activePlugins = []; + + // Dynamic extension -> Format map + final Map _pluginSupportedInput = {}; + // Dynamic Format -> Platform display name map + final Map _pluginPlatformMap = {}; + // Dynamic Format -> Hex color badge map + final Map _pluginFormatColors = {}; + // Dynamic Format -> List of Target Formats + final Map> _pluginConversionMap = {}; + // Dynamic "FROM->TO" -> RegisteredConversion details + final Map _pluginConversions = {}; + + void clear() { + _activePlugins.clear(); + _pluginSupportedInput.clear(); + _pluginPlatformMap.clear(); + _pluginFormatColors.clear(); + _pluginConversionMap.clear(); + _pluginConversions.clear(); + } + + void registerPlugin(PluginManifest manifest) { + // Avoid duplicate registration + _activePlugins.removeWhere((p) => p.id == manifest.id); + _activePlugins.add(manifest); + + final platformBin = manifest.currentPlatformBinary; + + for (final ext in manifest.extensions) { + final cleanExt = ext.ext.startsWith('.') + ? ext.ext.toLowerCase() + : '.${ext.ext.toLowerCase()}'; + final fmtUpper = ext.format.toUpperCase(); + + _pluginSupportedInput[cleanExt] = fmtUpper; + _pluginPlatformMap[fmtUpper] = ext.platform; + _pluginFormatColors[fmtUpper] = ext.color; + } + + for (final conv in manifest.conversions) { + final fromUpper = conv.from.toUpperCase(); + final toUpper = conv.to.toUpperCase(); + final routeKey = '$fromUpper->$toUpper'; + + final targets = _pluginConversionMap.putIfAbsent(fromUpper, () => []); + if (!targets.contains(toUpper)) { + targets.add(toUpper); + } + + _pluginConversions[routeKey] = RegisteredConversion( + pluginId: manifest.id, + from: fromUpper, + to: toUpper, + commandTemplate: conv.command, + executableRelPath: platformBin?.executable, + ); + } + } + + void unregisterPlugin(String pluginId) { + _activePlugins.removeWhere((p) => p.id == pluginId); + _rebuild(); + } + + void _rebuild() { + final copy = List.from(_activePlugins); + clear(); + for (final plugin in copy) { + registerPlugin(plugin); + } + } + + Map get supportedInput => + Map.unmodifiable(_pluginSupportedInput); + Map get platformMap => Map.unmodifiable(_pluginPlatformMap); + Map get formatColors => Map.unmodifiable(_pluginFormatColors); + Map> get conversionMap => + Map.unmodifiable(_pluginConversionMap); + + RegisteredConversion? getConversion(String from, String to) { + final key = '${from.toUpperCase()}->${to.toUpperCase()}'; + return _pluginConversions[key]; + } + + List get activePlugins => List.unmodifiable(_activePlugins); +} diff --git a/core/test/plugin_test.dart b/core/test/plugin_test.dart new file mode 100644 index 0000000..0d8ce4d --- /dev/null +++ b/core/test/plugin_test.dart @@ -0,0 +1,302 @@ +// OpenROM — Universal ROM Compression Suite +// M5 Dev | GPL v3 + +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:crypto/crypto.dart'; +import 'package:openrom_core/openrom_core.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('openrom_plugin_test_'); + PluginManager.instance.setCustomPluginsDir(p.join(tempDir.path, 'plugins')); + PluginRegistry.instance.clear(); + }); + + tearDown(() { + PluginRegistry.instance.clear(); + PluginManager.instance.setCustomPluginsDir(null); + try { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + } catch (_) {} + }); + + group('PluginManifest Schema Parsing & Validation', () { + test('parses valid manifest JSON correctly', () { + final jsonMap = { + 'id': 'xbox360-tools', + 'name': 'Xbox 360 Plugin', + 'version': '1.0.0', + 'author': 'OpenROM Team', + 'description': 'Plugin for Xbox 360 images', + 'minCoreVersion': '3.9.0', + 'binaries': { + 'windows_x64': { + 'executable': 'bin/tool.exe', + 'sha256': + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + }, + 'linux_x64': { + 'executable': 'bin/tool', + 'sha256': + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + }, + }, + 'extensions': [ + { + 'ext': '.zar', + 'format': 'ZAR', + 'platform': 'Xbox 360', + 'color': '#388e3c', + } + ], + 'conversions': [ + { + 'from': 'ISO', + 'to': 'ZAR', + 'command': '{bin} pack "{in}" "{out}"', + } + ], + }; + + final manifest = PluginManifest.fromJson(jsonMap); + expect(manifest.id, equals('xbox360-tools')); + expect(manifest.name, equals('Xbox 360 Plugin')); + expect(manifest.version, equals('1.0.0')); + expect(manifest.binaries.length, equals(2)); + expect( + manifest.binaries['windows_x64']?.executable, equals('bin/tool.exe')); + expect(manifest.extensions.length, equals(1)); + expect(manifest.extensions.first.format, equals('ZAR')); + expect(manifest.conversions.length, equals(1)); + expect(manifest.conversions.first.command, + equals('{bin} pack "{in}" "{out}"')); + + final errors = manifest.validate(); + expect(errors, isEmpty); + }); + + test('validates invalid manifest missing required fields', () { + final manifest = PluginManifest.fromJson({}); + final errors = manifest.validate(); + expect(errors, isNotEmpty); + expect(errors, contains('Plugin "id" is required.')); + expect(errors, contains('Plugin "name" is required.')); + expect(errors, contains('Plugin "version" is required.')); + expect(errors, contains('Plugin "minCoreVersion" is required.')); + }); + + test('validates invalid plugin ID format', () { + final manifest = PluginManifest.fromJson({ + 'id': 'invalid id with spaces!', + 'name': 'Test', + 'version': '1.0.0', + 'minCoreVersion': '3.9.0', + }); + final errors = manifest.validate(); + expect( + errors, + contains( + 'Plugin "id" must contain only alphanumeric characters, underscores, or hyphens.')); + }); + }); + + group('Plugin Installation, Extraction, and SHA256 Verification', () { + test( + 'installs valid .orplug ZIP archive and verifies platform binary SHA256', + () async { + final platKey = getCurrentPlatformKey(); + final dummyBinContent = 'mock_binary_executable_content_123'; + final dummyBinDigest = + sha256.convert(utf8.encode(dummyBinContent)).toString(); + + final manifestJson = { + 'id': 'test-plugin', + 'name': 'Test Dynamic Plugin', + 'version': '1.2.3', + 'author': 'Tester', + 'description': 'A mock test plugin', + 'minCoreVersion': '3.9.0', + 'binaries': { + platKey: { + 'executable': 'test_tool', + 'sha256': dummyBinDigest, + } + }, + 'extensions': [ + { + 'ext': '.mock', + 'format': 'MOCK', + 'platform': 'Mock Console', + 'color': '#ff00ff', + } + ], + 'conversions': [ + { + 'from': 'ISO', + 'to': 'MOCK', + 'command': '"{bin}" convert "{in}" "{out}"', + } + ], + }; + + // Create ZIP archive (.orplug) + final encoder = ZipFileEncoder(); + final orplugPath = p.join(tempDir.path, 'test_plugin.orplug'); + encoder.create(orplugPath); + + final manifestFile = File(p.join(tempDir.path, 'plugin.json')) + ..writeAsStringSync(jsonEncode(manifestJson)); + final binFile = File(p.join(tempDir.path, 'test_tool')) + ..writeAsStringSync(dummyBinContent); + + encoder.addFile(manifestFile, 'plugin.json'); + encoder.addFile(binFile, 'test_tool'); + encoder.close(); + + final installed = await PluginManager.instance.installPlugin(orplugPath); + expect(installed, isTrue); + + final activePlugins = PluginManager.instance.installedPlugins; + expect(activePlugins.length, equals(1)); + expect(activePlugins.first.id, equals('test-plugin')); + + // Check dynamic registry updates + expect(supportedInput['.mock'], equals('MOCK')); + expect(platformMap['MOCK'], equals('Mock Console')); + expect(formatColors['MOCK'], equals('#ff00ff')); + expect(getValidTargets('ISO'), contains('MOCK')); + + final cmdPreview = getCommandPreview('ISO', 'MOCK', 'game.iso'); + expect(cmdPreview, equals('"{bin}" convert "game.iso" "game.mock"')); + + // Test uninstall + final uninstalled = + await PluginManager.instance.uninstallPlugin('test-plugin'); + expect(uninstalled, isTrue); + expect(PluginManager.instance.installedPlugins, isEmpty); + expect(supportedInput.containsKey('.mock'), isFalse); + }); + + test('refuses installation when SHA256 checksum mismatches', () async { + final platKey = getCurrentPlatformKey(); + final manifestJson = { + 'id': 'corrupt-plugin', + 'name': 'Corrupt Plugin', + 'version': '1.0.0', + 'author': 'Tester', + 'description': 'SHA mismatch plugin', + 'minCoreVersion': '3.9.0', + 'binaries': { + platKey: { + 'executable': 'test_tool', + 'sha256': + 'bad_sha256_checksum_000000000000000000000000000000000000000000000', + } + }, + 'extensions': [], + 'conversions': [], + }; + + final encoder = ZipFileEncoder(); + final orplugPath = p.join(tempDir.path, 'corrupt.orplug'); + encoder.create(orplugPath); + + final manifestFile = File(p.join(tempDir.path, 'plugin.json')) + ..writeAsStringSync(jsonEncode(manifestJson)); + final binFile = File(p.join(tempDir.path, 'test_tool')) + ..writeAsStringSync('dummy content'); + + encoder.addFile(manifestFile, 'plugin.json'); + encoder.addFile(binFile, 'test_tool'); + encoder.close(); + + final installed = await PluginManager.instance.installPlugin(orplugPath); + expect(installed, isFalse); + expect(PluginManager.instance.installedPlugins, isEmpty); + }); + }); + + group('Converter `{bin}` Path Resolution & Plugin Execution', () { + test('resolves `{bin}` to absolute path during plugin conversion', + () async { + final platKey = getCurrentPlatformKey(); + final scriptName = Platform.isWindows ? 'mock_tool.bat' : 'mock_tool.sh'; + final scriptContent = Platform.isWindows + ? '@echo off\r\necho Converting %2 to %3\r\nexit 0\r\n' + : '#!/bin/sh\necho "Converting \$2 to \$3"\nexit 0\n'; + + final scriptDigest = + sha256.convert(utf8.encode(scriptContent)).toString(); + + final manifestJson = { + 'id': 'script-plugin', + 'name': 'Script Tool Plugin', + 'version': '2.0.0', + 'author': 'OpenROM', + 'description': 'Script based converter', + 'minCoreVersion': '3.9.0', + 'binaries': { + platKey: { + 'executable': scriptName, + 'sha256': scriptDigest, + } + }, + 'extensions': [ + { + 'ext': '.srcfmt', + 'format': 'SRCFMT', + 'platform': 'Custom System', + 'color': '#123456', + } + ], + 'conversions': [ + { + 'from': 'SRCFMT', + 'to': 'TGTFMT', + 'command': '"{bin}" convert "{in}" "{out}"', + } + ], + }; + + final encoder = ZipFileEncoder(); + final orplugPath = p.join(tempDir.path, 'script.orplug'); + encoder.create(orplugPath); + + final manifestFile = File(p.join(tempDir.path, 'plugin.json')) + ..writeAsStringSync(jsonEncode(manifestJson)); + final scriptFile = File(p.join(tempDir.path, scriptName)) + ..writeAsStringSync(scriptContent); + + encoder.addFile(manifestFile, 'plugin.json'); + encoder.addFile(scriptFile, scriptName); + encoder.close(); + + final installed = await PluginManager.instance.installPlugin(orplugPath); + expect(installed, isTrue); + + final dummyInput = File(p.join(tempDir.path, 'sample.srcfmt')) + ..writeAsStringSync('dummy input ROM'); + + final job = ConversionJob( + filepath: dummyInput.path, + outputDir: tempDir.path, + targetFormat: 'TGTFMT', + ); + + final converter = Converter(); + final success = await converter.convert(job); + + expect(success, isTrue); + expect(job.status, equals('Done')); + }); + }); +}