diff --git a/core/bin/openrom.dart b/core/bin/openrom.dart index 19df28b..421634b 100644 --- a/core/bin/openrom.dart +++ b/core/bin/openrom.dart @@ -873,7 +873,7 @@ Future main(List args) async { int failed = 0; for (final job in jobs) { - final ok = converter.convert(job); + final ok = await converter.convert(job); if (!isJson) _clearProgress(); if (ok) { if (isJson) _jsonPrint({'type': 'done', 'success': true}); diff --git a/core/lib/src/converter.dart b/core/lib/src/converter.dart index 6694c5d..5dd03ec 100644 --- a/core/lib/src/converter.dart +++ b/core/lib/src/converter.dart @@ -1,4 +1,3 @@ -import 'config.dart' as config; // OpenROM — Universal ROM Compression Suite // M5 Dev | GPL v3 @@ -7,6 +6,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; +import 'config.dart' as config; import 'cue_generator.dart'; import 'detector.dart' as detector; import 'logger.dart' as logger; @@ -47,6 +47,65 @@ const Map chdDvdCompression = { 'Max': 'zstd,zlib,huff,flac', }; +List cleanCommandArgs(List args) { + return args.map((arg) { + var a = arg.trim(); + if ((a.startsWith('"') && a.endsWith('"')) || + (a.startsWith("'") && a.endsWith("'"))) { + if (a.length >= 2) { + a = a.substring(1, a.length - 1); + } + } + return a; + }).toList(); +} + +List splitCommandArgs(String cmdLine) { + final List result = []; + final StringBuffer current = StringBuffer(); + bool inDoubleQuotes = false; + bool inSingleQuotes = false; + + for (int i = 0; i < cmdLine.length; i++) { + final char = cmdLine[i]; + if (char == '"' && !inSingleQuotes) { + inDoubleQuotes = !inDoubleQuotes; + } else if (char == "'" && !inDoubleQuotes) { + inSingleQuotes = !inSingleQuotes; + } else if (char == ' ' && !inDoubleQuotes && !inSingleQuotes) { + if (current.isNotEmpty) { + result.add(current.toString()); + current.clear(); + } + } else { + current.write(char); + } + } + if (current.isNotEmpty) { + result.add(current.toString()); + } + + return cleanCommandArgs(result); +} + +Future processStreamLines( + Stream> stream, + void Function(String line) onLine, +) async { + String buffer = ''; + await for (final chunk in stream.transform(utf8.decoder)) { + buffer += chunk; + final parts = buffer.split(RegExp(r'[\r\n]+')); + for (int i = 0; i < parts.length - 1; i++) { + onLine(parts[i]); + } + buffer = parts.last; + } + if (buffer.isNotEmpty) { + onLine(buffer); + } +} + class ConversionJob { final String filepath; final String outputDir; @@ -102,10 +161,10 @@ class Converter { _stopped = true; } - bool convert(ConversionJob job) { + Future convert(ConversionJob job) async { job.status = 'Converting'; try { - bool ok = _dispatch(job); + bool ok = await _dispatch(job); if (ok && job.verify && job.targetFormat.toUpperCase() == 'CHD') { final chdOut = _outPath(job, job.filepath, '.chd'); if (File(chdOut).existsSync()) { @@ -120,14 +179,14 @@ class Converter { } catch (e) { job.error = e.toString(); job.status = 'Failed'; - _log('[ERROR] $e'); + _log('[ERROR] '); return false; } finally { _cleanup(job); } } - bool _dispatch(ConversionJob job) { + Future _dispatch(ConversionJob job) async { final info = job.getFileInfo(); final src = job.filepath; @@ -148,76 +207,76 @@ class Converter { final tgt = job.targetFormat.toUpperCase(); if (fmt == 'ECM') { - return _fromEcm(job, src, tgt); + return await _fromEcm(job, src, tgt); } final valid = detector.getValidTargets(fmt); if (!valid.contains(tgt) && tgt != 'BIN/CUE' && tgt != 'FILES') { - final err = 'Cannot convert $fmt → $tgt. ' - 'Supported targets for $fmt: ${valid.isNotEmpty ? valid.join(', ') : 'none'}'; - _log('[ERROR] $err'); + final err = 'Cannot convert → . ' + 'Supported targets for : ${valid.isNotEmpty ? valid.join(", ") : "none"}'; + _log('[ERROR] '); job.error = err; return false; } if (tgt == 'CHD') { - return _toChd(job, src, fmt, info); + return await _toChd(job, src, fmt, info); } if (fmt == 'CHD' && (tgt == 'ISO' || tgt == 'BIN' || tgt == 'BIN/CUE')) { - return _fromChd(job, src, tgt, info); + return await _fromChd(job, src, tgt, info); } if (tgt == 'CSO') { - return _toCso(job, src); + return await _toCso(job, src); } if (tgt == 'RVZ') { - return _toRvz(job, src); + return await _toRvz(job, src); } if (['RVZ', 'WIA', 'WBFS', 'GCZ'].contains(fmt) && tgt == 'ISO') { - return _fromNodtool(job, src, tgt); + return await _fromNodtool(job, src, tgt); } if ((fmt == 'CSO' || fmt == 'ZSO') && tgt == 'ISO') { - return _csoToIso(job, src); + return await _csoToIso(job, src); } if (tgt == 'ECM') { - return _toEcm(job, src); + return await _toEcm(job, src); } if (fmt == 'XISO' && (tgt == 'ISO' || tgt == 'FILES')) { - return _xisoToFiles(job, src); + return await _xisoToFiles(job, src); } if (tgt == 'XISO') { - return _toXiso(job, src); + return await _toXiso(job, src); } if ((['WUD', 'WUX', 'NKIT', 'WUA'].contains(fmt) && ['ISO', 'WUA', 'WUD'].contains(tgt)) || (fmt == 'ISO' && tgt == 'NKIT')) { - return _nkitConvert(job, src, tgt); + return await _nkitConvert(job, src, tgt); } if ((fmt == 'CCI' || fmt == 'ZAR') && tgt == 'ISO') { - return _xboxConvert(job, src, fmt); + return await _xboxConvert(job, src, fmt); } - final err = 'Unhandled conversion route: $fmt → $tgt'; - _log('[ERROR] $err'); + final err = 'Unhandled conversion route: → '; + _log('[ERROR] '); job.error = err; return false; } - bool _toChd( + Future _toChd( ConversionJob job, String src, String fmt, Map info, - ) { + ) async { final chdman = detector.getChdmanPath(); final out = _outPath(job, src, '.chd'); @@ -247,7 +306,7 @@ class Converter { } else { final err = "Unsupported or ambiguous media type for platform '$platform'. Please specify --media cd or --media dvd."; - _log('[ERROR] $err'); + _log('[ERROR] '); job.error = err; return false; } @@ -299,15 +358,15 @@ class Converter { } _log('[CHD] ${p.basename(src)} → ${p.basename(out)}'); - return _run(cmd, job); + return await _run(cmd, job); } - bool _fromChd( + Future _fromChd( ConversionJob job, String src, String tgt, Map info, - ) { + ) async { final chdman = detector.getChdmanPath(); final chdType = info['chd_type'] as String? ?? 'cd'; @@ -340,11 +399,11 @@ class Converter { } } - _log('[CHD EXTRACT] ${p.basename(src)} → $tgt'); - return _run(cmd, job); + _log('[CHD EXTRACT] ${p.basename(src)} → '); + return await _run(cmd, job); } - bool _toCso(ConversionJob job, String src) { + Future _toCso(ConversionJob job, String src) async { final maxcso = config.getToolPath('maxcso'); final out = _outPath(job, src, '.cso'); final threads = Platform.numberOfProcessors; @@ -355,32 +414,32 @@ class Converter { cmd.insert(1, '--use-zlib'); } _log('[CSO] ${p.basename(src)} → ${p.basename(out)}'); - return _run(cmd, job); + return await _run(cmd, job); } - bool _csoToIso(ConversionJob job, String src) { + Future _csoToIso(ConversionJob job, String src) async { final maxcso = config.getToolPath('maxcso'); final out = _outPath(job, src, '.iso'); final cmd = [maxcso, '--decompress', src, '-o', out]; _log('[CSO→ISO] ${p.basename(src)} → ${p.basename(out)}'); - return _run(cmd, job); + return await _run(cmd, job); } - bool _toEcm(ConversionJob job, String src) { + Future _toEcm(ConversionJob job, String src) async { final ecm = config.getToolPath('ecm'); final out = _outPath(job, src, 'ecm'); final cmd = [ecm, src, out]; _log('[ECM] ${p.basename(src)} → ${p.basename(out)}'); - return _run(cmd, job); + return await _run(cmd, job); } - bool _fromEcm(ConversionJob job, String src, String tgt) { + Future _fromEcm(ConversionJob job, String src, String tgt) async { final unecm = config.getToolPath('unecm'); final out = _outPath(job, src, tgt); final cmd = [unecm, src, out]; _log('[UNECM] ${p.basename(src)} → ${p.basename(out)}'); - final ok = _run(cmd, job); + final ok = await _run(cmd, job); if (ok && File(out).existsSync()) { final extFound = detector.getExtension(out); final targetExt = tgt.toLowerCase(); @@ -393,62 +452,62 @@ class Converter { File(out).renameSync(newOut); _log('[UNECM AUTO-DETECT] Renamed output to: ${p.basename(newOut)}'); } catch (e) { - _log('[WARN] Failed to rename $out to $newOut: $e'); + _log('[WARN] Failed to rename to : '); } } } return ok; } - bool _toXiso(ConversionJob job, String src) { + Future _toXiso(ConversionJob job, String src) async { final xiso = config.getToolPath('extract-xiso'); final cmd = [xiso, '-r', src]; _log('[XISO] ${p.basename(src)} — repacking to XISO...'); - return _run(cmd, job); + return await _run(cmd, job); } - bool _xisoToFiles(ConversionJob job, String src) { + Future _xisoToFiles(ConversionJob job, String src) async { final xiso = config.getToolPath('extract-xiso'); final base = p.basenameWithoutExtension(src); 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)} → $outDir/'); + _log('[XISO→FILES] Extracting ${p.basename(src)} → /'); _log( '[XISO→FILES] Output is a folder of extracted files, not a single ISO.', ); - return _run(cmd, job); + return await _run(cmd, job); } - bool _toRvz(ConversionJob job, String src) { + Future _toRvz(ConversionJob job, String src) async { final nodtool = config.getToolPath('nodtool'); final outName = detector.getOutputName(src, 'rvz'); final out = p.join(job.outputDir, p.basename(outName)); final cmd = [nodtool, 'convert', src, out]; _log('[RVZ] ${p.basename(src)} → ${p.basename(out)}'); - return _run(cmd, job); + return await _run(cmd, job); } - bool _fromNodtool(ConversionJob job, String src, String tgt) { + Future _fromNodtool(ConversionJob job, String src, String tgt) async { final nodtool = config.getToolPath('nodtool'); final outName = detector.getOutputName(src, 'iso'); final out = p.join(job.outputDir, p.basename(outName)); final fmt = p.extension(src).toUpperCase().replaceAll('.', ''); final cmd = [nodtool, 'convert', src, out]; _log('[$fmt→ISO] ${p.basename(src)} → ${p.basename(out)}'); - return _run(cmd, job); + return await _run(cmd, job); } - bool _nkitConvert(ConversionJob job, String src, String tgt) { + Future _nkitConvert(ConversionJob job, String src, String tgt) async { final nkit = config.getToolPath('nkit'); final ext = tgt == 'NKIT' ? 'nkit.iso' : tgt.toLowerCase(); final out = _outPath(job, src, ext); final cmd = [nkit, 'convert', '-i', src, '-o', out]; _log('[NKIT] ${p.basename(src)} → ${p.basename(out)}'); - return _run(cmd, job); + return await _run(cmd, job); } - bool _xboxConvert(ConversionJob job, String src, String fmt) { + Future _xboxConvert(ConversionJob job, String src, String fmt) async { if (Platform.isMacOS) { job.error = 'Xbox format conversion is not supported on macOS. ' 'XGDTool does not currently provide a macOS build.'; @@ -458,11 +517,12 @@ class Converter { final xgdtool = config.getToolPath('xgdtool'); final cmd = [xgdtool, '--xiso', src, '--out', job.outputDir]; _log('[Xbox→ISO] ${p.basename(src)} → ISO via XGDTool'); - return _run(cmd, job); + return await _run(cmd, job); } - bool _run(List cmd, ConversionJob job) { - final toolPath = cmd.first; + Future _run(List cmd, ConversionJob job) async { + final cleanedCmd = cleanCommandArgs(cmd); + final toolPath = cleanedCmd.first; final isAbsolute = p.isAbsolute(toolPath); if (isAbsolute && !File(toolPath).existsSync()) { final toolName = p.basename(toolPath); @@ -470,35 +530,32 @@ 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(' ❌ $err'); + _log(' ❌ '); return false; } _log( - ' cmd: ${cmd.asMap().entries.map((e) => e.key == 0 ? p.basename(e.value) : e.value).join(' ')}', + ' cmd: ${cleanedCmd.asMap().entries.map((e) => e.key == 0 ? p.basename(e.value) : e.value).join(' ')}', ); String lastLine = ''; + final stderrBuffer = StringBuffer(); try { - final process = Process.runSync( - cmd.first, - cmd.sublist(1), - stdoutEncoding: utf8, - stderrEncoding: utf8, + final process = await Process.start( + cleanedCmd.first, + cleanedCmd.sublist(1), ); - final combined = '${process.stdout}\n${process.stderr}'; - final lines = const LineSplitter().convert(combined); - - for (final line in lines) { - if (_stopped) { - job.error = 'Process terminated by user'; - return false; - } + void handleLine(String line, {bool isStderr = false}) { + if (_stopped) return; final trimmed = line.trimRight(); if (trimmed.isNotEmpty) { lastLine = trimmed; - _log(' $trimmed'); + _log(' '); + if (isStderr) { + if (stderrBuffer.isNotEmpty) stderrBuffer.writeln(); + stderrBuffer.write(trimmed); + } final pct = _parseProgress(trimmed); if (pct != null) { job.progress = pct; @@ -507,15 +564,34 @@ class Converter { } } - if (process.exitCode == 0) { + final stdoutDone = processStreamLines( + process.stdout, + (line) => handleLine(line), + ); + final stderrDone = processStreamLines( + process.stderr, + (line) => handleLine(line, isStderr: true), + ); + + await Future.wait([stdoutDone, stderrDone]); + final exitCode = await process.exitCode; + + if (_stopped) { + job.error = 'Process terminated by user'; + return false; + } + + if (exitCode == 0) { _log(' ✅ Command executed successfully'); job.progress = 100.0; if (onProgress != null) onProgress!(job, 100.0); return true; } else { - final err = lastLine.isNotEmpty - ? 'Process failed (code ${process.exitCode}): $lastLine' - : 'Process failed with exit code ${process.exitCode}'; + final toolName = p.basename(toolPath); + final errDetails = stderrBuffer.isNotEmpty + ? stderrBuffer.toString().trim() + : (lastLine.isNotEmpty ? lastLine : 'Unknown error'); + final err = '$toolName failed with exit code $exitCode: $errDetails'; job.error = err; _log(' ❌ $err'); return false; @@ -581,7 +657,7 @@ class Converter { double? _parseProgress(String line) { if (RegExp( - r'\b(?:cpu|memory|ram|disk|swap|usage|load)\b', + r'(?:cpu|memory|ram|disk|swap|usage|load)', caseSensitive: false, ).hasMatch(line)) { return null; diff --git a/core/test/argument_parsing_test.dart b/core/test/argument_parsing_test.dart new file mode 100644 index 0000000..d47f923 --- /dev/null +++ b/core/test/argument_parsing_test.dart @@ -0,0 +1,70 @@ +import 'package:openrom_core/openrom_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('Argument Tokenization & Quote Stripping', () { + test('cleanCommandArgs strips literal surrounding double quotes', () { + final input = [ + 'chdman', + 'createcd', + '-i', + '"/path/to/my game.cue"', + '-o', + '"/path/to/my game.chd"' + ]; + final cleaned = cleanCommandArgs(input); + expect( + cleaned, + equals([ + 'chdman', + 'createcd', + '-i', + '/path/to/my game.cue', + '-o', + '/path/to/my game.chd' + ])); + }); + + test('cleanCommandArgs strips literal surrounding single quotes', () { + final input = [ + 'maxcso', + "'/path/to/rom.iso'", + '-o', + "'/path/to/rom.cso'" + ]; + final cleaned = cleanCommandArgs(input); + expect(cleaned, + equals(['maxcso', '/path/to/rom.iso', '-o', '/path/to/rom.cso'])); + }); + + test( + 'splitCommandArgs tokenizes command string with quotes and spaces correctly', + () { + const cmdStr = + 'chdman createcd -i "C:\\My Games\\game.cue" -o "C:\\My Games\\game.chd" --compression cdlz,cdfl'; + final tokens = splitCommandArgs(cmdStr); + expect( + tokens, + equals([ + 'chdman', + 'createcd', + '-i', + 'C:\\My Games\\game.cue', + '-o', + 'C:\\My Games\\game.chd', + '--compression', + 'cdlz,cdfl', + ]), + ); + }); + + test('splitCommandArgs handles mixed quotes and unquoted arguments', () { + const cmdStr = "nodtool convert 'my disc/game.gcz' \"out dir/game.iso\""; + final tokens = splitCommandArgs(cmdStr); + expect( + tokens, + equals( + ['nodtool', 'convert', 'my disc/game.gcz', 'out dir/game.iso'])); + }); + }); +} diff --git a/core/test/chd_audio_codec_test.dart b/core/test/chd_audio_codec_test.dart index 85b87c9..21833c2 100644 --- a/core/test/chd_audio_codec_test.dart +++ b/core/test/chd_audio_codec_test.dart @@ -5,7 +5,7 @@ import 'package:path/path.dart' as p; import 'package:test/test.dart'; void main() { - test('chd_audio_codec_flac_default', () { + test('chd_audio_codec_flac_default', () async { final tmpDir = Directory.systemTemp.createTempSync('chd_codec_flac_'); try { final cuePath = p.join(tmpDir.path, 'game.cue'); @@ -30,7 +30,7 @@ void main() { final logs = []; final converter = Converter(onLogCb: (msg) => logs.add(msg)); - converter.convert(job); + await converter.convert(job); expect(job.audioCodec, equals('flac')); final cmdLog = @@ -42,7 +42,7 @@ void main() { } }); - test('chd_audio_codec_vorbis', () { + test('chd_audio_codec_vorbis', () async { final tmpDir = Directory.systemTemp.createTempSync('chd_codec_vorbis_'); try { final cuePath = p.join(tmpDir.path, 'game.cue'); @@ -67,7 +67,7 @@ void main() { final logs = []; final converter = Converter(onLogCb: (msg) => logs.add(msg)); - converter.convert(job); + await converter.convert(job); expect(job.audioCodec, equals('vorbis')); final cmdLog = diff --git a/core/test/detector_test.dart b/core/test/detector_test.dart index 92921f5..77d8c25 100644 --- a/core/test/detector_test.dart +++ b/core/test/detector_test.dart @@ -170,7 +170,7 @@ void main() { } }); - test('missing_tool_preflight_check', () { + test('missing_tool_preflight_check', () async { final tmpDir = Directory.systemTemp.createTempSync('missing_tool_test_'); try { final dummyFile = p.join(tmpDir.path, 'dummy.cci'); @@ -186,7 +186,7 @@ void main() { ); final converter = Converter(); - final success = converter.convert(job); + final success = await converter.convert(job); expect(success, isFalse); expect(job.status, equals('Failed')); @@ -203,7 +203,7 @@ void main() { expect(xgdtoolPath, isNotEmpty); }); - test('unknown_iso_conversion_without_media_fails_safely', () { + test('unknown_iso_conversion_without_media_fails_safely', () async { final tmpDir = Directory.systemTemp.createTempSync('unknown_iso_conv_'); try { final isoPath = p.join(tmpDir.path, 'unidentified.iso'); @@ -216,7 +216,7 @@ void main() { ); final converter = Converter(); - final success = converter.convert(job); + final success = await converter.convert(job); expect(success, isFalse); expect(job.status, equals('Failed')); @@ -231,7 +231,7 @@ void main() { } }); - test('unknown_iso_conversion_with_media_override_routes_correctly', () { + test('unknown_iso_conversion_with_media_override_routes_correctly', () async { final tmpDir = Directory.systemTemp.createTempSync('unknown_iso_override_'); try { final isoPath = p.join(tmpDir.path, 'unidentified.iso'); @@ -247,7 +247,7 @@ void main() { ); final converter = Converter(); - converter.convert(jobCd); + await converter.convert(jobCd); expect(jobCd.error, contains('Tool not found')); expect(jobCd.error, isNot(contains('Platform could not be detected'))); @@ -257,7 +257,7 @@ void main() { targetFormat: 'CHD', media: 'dvd', ); - converter.convert(jobDvd); + await converter.convert(jobDvd); expect(jobDvd.error, contains('Tool not found')); expect(jobDvd.error, isNot(contains('Platform could not be detected'))); } finally { @@ -266,7 +266,8 @@ void main() { } }); - test('unmapped_custom_platform_conversion_without_media_fails_safely', () { + test('unmapped_custom_platform_conversion_without_media_fails_safely', + () async { final tmpDir = Directory.systemTemp.createTempSync('unmapped_platform_test_'); try { @@ -281,7 +282,7 @@ void main() { ); final converter = Converter(); - final success = converter.convert(job); + final success = await converter.convert(job); expect(success, isFalse); expect(job.status, equals('Failed')); @@ -296,7 +297,8 @@ void main() { } }); - test('unmapped_custom_platform_conversion_with_media_override_succeeds', () { + test('unmapped_custom_platform_conversion_with_media_override_succeeds', + () async { final tmpDir = Directory.systemTemp.createTempSync('unmapped_platform_override_'); try { @@ -314,7 +316,7 @@ void main() { ); final converter = Converter(); - converter.convert(job); + await converter.convert(job); expect(job.error, contains('Tool not found')); expect(job.error, isNot(contains('Platform could not be detected'))); diff --git a/core/test/stream_progress_test.dart b/core/test/stream_progress_test.dart new file mode 100644 index 0000000..5a6a9ee --- /dev/null +++ b/core/test/stream_progress_test.dart @@ -0,0 +1,51 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:openrom_core/openrom_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('Stream Progress Parser', () { + test('processStreamLines emits lines separated by carriage return \\r', + () async { + final controller = StreamController>(); + final receivedLines = []; + + final future = processStreamLines(controller.stream, (line) { + receivedLines.add(line); + }); + + // Simulate chdman emitting real-time percentage progress with \r + controller.add(utf8.encode("Compressing, 10.5% complete...\r")); + controller.add(utf8.encode("Compressing, 45.2% complete...\r")); + controller.add(utf8.encode("Compressing, 100.0% complete...\r\n")); + + await controller.close(); + await future; + + expect(receivedLines, contains("Compressing, 10.5% complete...")); + expect(receivedLines, contains("Compressing, 45.2% complete...")); + expect(receivedLines, contains("Compressing, 100.0% complete...")); + }); + + test('processStreamLines handles split chunks across carriage return', + () async { + final controller = StreamController>(); + final receivedLines = []; + + final future = processStreamLines(controller.stream, (line) { + receivedLines.add(line); + }); + + // Split across two chunks + controller.add(utf8.encode("Compressing, 2")); + controller.add(utf8.encode("5.0% complete...\rCompressing, 50.0%")); + controller.add(utf8.encode(" complete...\r")); + + await controller.close(); + await future; + + expect(receivedLines, contains("Compressing, 25.0% complete...")); + expect(receivedLines, contains("Compressing, 50.0% complete...")); + }); + }); +} diff --git a/gui/lib/controllers/home_controller.dart b/gui/lib/controllers/home_controller.dart index 3693a66..2981def 100644 --- a/gui/lib/controllers/home_controller.dart +++ b/gui/lib/controllers/home_controller.dart @@ -163,6 +163,12 @@ class HomeController extends ChangeNotifier { if (onError != null) { onError(e); } + } catch (e, stack) { + job.status = JobStatus.failed; + job.error = OpenROMError.unknownError; + job.errorMessage = e.toString(); + _logs.add('[ERROR] Unhandled exception: $e\n$stack'); + notifyListeners(); } } diff --git a/gui/lib/services/core_bridge.dart b/gui/lib/services/core_bridge.dart index e57ef20..e360833 100644 --- a/gui/lib/services/core_bridge.dart +++ b/gui/lib/services/core_bridge.dart @@ -24,6 +24,21 @@ class OpenROMException implements Exception { String toString() => 'OpenROMException($error, details: $details)'; } +Stream splitStreamLines(Stream> stream) async* { + String buffer = ''; + await for (final chunk in stream.transform(utf8.decoder)) { + buffer += chunk; + final parts = buffer.split(RegExp(r'[\r\n]+')); + for (int i = 0; i < parts.length - 1; i++) { + yield parts[i]; + } + buffer = parts.last; + } + if (buffer.isNotEmpty) { + yield buffer; + } +} + class CoreBridge { static String? _cachedCorePath; @@ -185,10 +200,7 @@ class CoreBridge { final process = await Process.start(corePath, args); final stderrLog = StringBuffer(); - process.stdout - .transform(utf8.decoder) - .transform(const LineSplitter()) - .listen((line) { + splitStreamLines(process.stdout).listen((line) { if (line.trim().isEmpty) return; try { final Map event = jsonDecode(line); @@ -213,10 +225,7 @@ class CoreBridge { } }); - process.stderr - .transform(utf8.decoder) - .transform(const LineSplitter()) - .listen((line) { + splitStreamLines(process.stderr).listen((line) { if (line.trim().isNotEmpty) { onLog('[STDERR] $line'); stderrLog.writeln(line); @@ -304,10 +313,7 @@ class CoreBridge { final process = await Process.start(corePath, args); final stderrLog = StringBuffer(); - process.stdout - .transform(utf8.decoder) - .transform(const LineSplitter()) - .listen((line) { + splitStreamLines(process.stdout).listen((line) { if (line.trim().isEmpty) return; try { final Map event = jsonDecode(line); @@ -332,10 +338,7 @@ class CoreBridge { } }); - process.stderr - .transform(utf8.decoder) - .transform(const LineSplitter()) - .listen((line) { + splitStreamLines(process.stderr).listen((line) { if (line.trim().isNotEmpty) { onLog?.call('[STDERR] $line'); stderrLog.writeln(line); @@ -401,10 +404,7 @@ class CoreBridge { final process = await Process.start(corePath, args); final stderrLog = StringBuffer(); - process.stdout - .transform(utf8.decoder) - .transform(const LineSplitter()) - .listen((line) { + splitStreamLines(process.stdout).listen((line) { if (line.trim().isEmpty) return; try { final Map event = jsonDecode(line); @@ -429,10 +429,7 @@ class CoreBridge { } }); - process.stderr - .transform(utf8.decoder) - .transform(const LineSplitter()) - .listen((line) { + splitStreamLines(process.stderr).listen((line) { if (line.trim().isNotEmpty) { onLog?.call('[STDERR] $line'); stderrLog.writeln(line); diff --git a/gui/lib/services/tools_service.dart b/gui/lib/services/tools_service.dart index 44d3ab2..ed751d2 100644 --- a/gui/lib/services/tools_service.dart +++ b/gui/lib/services/tools_service.dart @@ -50,6 +50,21 @@ class BinMergeResult { }); } +Stream _splitStreamLines(Stream> stream) async* { + String buffer = ''; + await for (final chunk in stream.transform(utf8.decoder)) { + buffer += chunk; + final parts = buffer.split(RegExp(r'[\r\n]+')); + for (int i = 0; i < parts.length - 1; i++) { + yield parts[i]; + } + buffer = parts.last; + } + if (buffer.isNotEmpty) { + yield buffer; + } +} + class ToolsService { Future generateCue({ required String binPath, @@ -148,10 +163,7 @@ class ToolsService { final stderrLog = StringBuffer(); BinMergeResult? mergeResult; - process.stdout - .transform(utf8.decoder) - .transform(const LineSplitter()) - .listen((line) { + _splitStreamLines(process.stdout).listen((line) { if (line.trim().isEmpty) return; try { final Map event = jsonDecode(line); @@ -171,10 +183,7 @@ class ToolsService { } catch (_) {} }); - process.stderr - .transform(utf8.decoder) - .transform(const LineSplitter()) - .listen((line) { + _splitStreamLines(process.stderr).listen((line) { if (line.trim().isNotEmpty) { stderrLog.writeln(line); } diff --git a/gui/pubspec.lock b/gui/pubspec.lock index a197066..8c1f559 100644 --- a/gui/pubspec.lock +++ b/gui/pubspec.lock @@ -185,10 +185,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.20.3" + version: "0.20.2" jni: dependency: transitive description: @@ -265,10 +265,10 @@ packages: dependency: transitive description: name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.20" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -281,10 +281,10 @@ packages: dependency: transitive description: name: meta - sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.17.0" objective_c: dependency: transitive description: @@ -549,10 +549,10 @@ packages: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.9" typed_data: dependency: transitive description: @@ -629,10 +629,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.4.3" + version: "2.2.0" vm_service: dependency: transitive description: diff --git a/gui/pubspec.yaml b/gui/pubspec.yaml index 1c24275..02e8ad6 100644 --- a/gui/pubspec.yaml +++ b/gui/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: crypto: ^3.0.6 http: ^1.2.0 url_launcher: ^6.3.0 - intl: ^0.20.3 + intl: ^0.20.2 path: ^1.9.0 desktop_drop: ^0.4.0 file_picker: 8.1.2