diff --git a/packages/shorebird_cli/lib/src/commands/patch/macos_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/macos_patcher.dart index 0820bf0b..24d91529 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/macos_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/macos_patcher.dart @@ -17,7 +17,6 @@ import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/logging/shorebird_logger.dart'; import 'package:shorebird_cli/src/metadata/metadata.dart'; import 'package:shorebird_cli/src/patch_diff_checker.dart'; -import 'package:shorebird_cli/src/platform/apple/plist.dart'; import 'package:shorebird_cli/src/platform/platform.dart'; import 'package:shorebird_cli/src/release_type.dart'; import 'package:shorebird_cli/src/shorebird_artifacts.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/preview_command.dart b/packages/shorebird_cli/lib/src/commands/preview_command.dart index 4169384d..ae7ec2a0 100644 --- a/packages/shorebird_cli/lib/src/commands/preview_command.dart +++ b/packages/shorebird_cli/lib/src/commands/preview_command.dart @@ -394,10 +394,7 @@ This is only applicable when previewing Android releases.''', await process.run('chmod', ['+x', executableFile.path]); - final proc = await process.start(executableFile.path, []); - proc.stdout.listen((log) => logger.info(utf8.decode(log))); - proc.stderr.listen((log) => logger.err(utf8.decode(log))); - return proc.exitCode; + return startAndForwardOutput(executableFile.path); } /// Downloads and runs the given [release] the given [appId] on Windows. @@ -463,10 +460,7 @@ This is only applicable when previewing Android releases.''', (file) => file.path.endsWith('.exe'), ); - final proc = await process.start(exeFile.path, []); - proc.stdout.listen((log) => logger.info(utf8.decode(log))); - proc.stderr.listen((log) => logger.err(utf8.decode(log))); - return proc.exitCode; + return startAndForwardOutput(exeFile.path); } /// Installs and launches the release on macOS. @@ -553,8 +547,12 @@ This is only applicable when previewing Android releases.''', return line.trim().replaceFirst(prefixRegex, '').trim(); } + // TODO(eseidel): Use startAndForwardOutput instead? + // This doesn't seem to handle stderr, maybe it should? + // Use allowMalformed to handle non-UTF8 bytes in log stream output. + const decoder = Utf8Decoder(allowMalformed: true); logs.listen((log) { - final logLine = utf8.decode(log); + final logLine = decoder.convert(log); if (logFilters.any((filter) => filter.hasMatch(logLine))) { return; } @@ -711,11 +709,14 @@ This is only applicable when previewing Android releases.''', } final process = await adb.logcat(filter: 'flutter', deviceId: deviceId); + // adb logcat sometimes lets non-utf8 characters through, so we need to + // not crash when it does. + const decoder = Utf8Decoder(allowMalformed: true); process.stdout.listen((event) { - logger.info(utf8.decode(event)); + logger.info(decoder.convert(event)); }); process.stderr.listen((event) { - logger.err(utf8.decode(event)); + logger.err(decoder.convert(event)); }); return process.exitCode; @@ -1018,6 +1019,18 @@ This is only applicable when previewing Android releases.''', shorebirdYamlFile.writeAsStringSync(yamlEditor.toString(), flush: true); return true; } + + /// Starts a process and forwards its stdout/stderr to the logger. + /// + /// Returns the process exit code. + Future startAndForwardOutput(String executable) async { + final proc = await process.start(executable, []); + // Use allowMalformed to handle non-UTF8 bytes in process output. + const decoder = Utf8Decoder(allowMalformed: true); + proc.stdout.listen((log) => logger.info(decoder.convert(log))); + proc.stderr.listen((log) => logger.err(decoder.convert(log))); + return proc.exitCode; + } } /// Extension on [Release] that exposes the active platforms (e.g. platforms diff --git a/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart index 29136c1e..f27c4dba 100644 --- a/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart @@ -14,7 +14,6 @@ import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/metadata/metadata.dart'; import 'package:shorebird_cli/src/platform/apple/apple.dart'; -import 'package:shorebird_cli/src/platform/apple/plist.dart'; import 'package:shorebird_cli/src/release_type.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; import 'package:shorebird_cli/src/shorebird_validator.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart index 5e2f0313..39cef48c 100644 --- a/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart @@ -13,7 +13,6 @@ import 'package:shorebird_cli/src/executables/xcodebuild.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/logging/shorebird_logger.dart'; import 'package:shorebird_cli/src/metadata/update_release_metadata.dart'; -import 'package:shorebird_cli/src/platform/apple/plist.dart'; import 'package:shorebird_cli/src/platform/platform.dart'; import 'package:shorebird_cli/src/release_type.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; diff --git a/packages/shorebird_cli/lib/src/executables/aot_tools.dart b/packages/shorebird_cli/lib/src/executables/aot_tools.dart index ccdb8113..2754b779 100644 --- a/packages/shorebird_cli/lib/src/executables/aot_tools.dart +++ b/packages/shorebird_cli/lib/src/executables/aot_tools.dart @@ -214,6 +214,7 @@ class AotTools { return version >= Version(0, 0, 1); } + /// Returns the version of the aot_tools executable. @visibleForTesting Future getVersion() async { // Use 0.0.0 to allow callers to easily compare w/o checking for null. diff --git a/packages/shorebird_cli/lib/src/executables/idevicesyslog.dart b/packages/shorebird_cli/lib/src/executables/idevicesyslog.dart index 0e3790e1..7d0262ec 100644 --- a/packages/shorebird_cli/lib/src/executables/idevicesyslog.dart +++ b/packages/shorebird_cli/lib/src/executables/idevicesyslog.dart @@ -88,12 +88,14 @@ class IDeviceSysLog { environment: {'DYLD_LIBRARY_PATH': _dyldPathEntry}, ); + // Use allowMalformed to handle non-UTF8 bytes in device syslog output. + const decoder = Utf8Decoder(allowMalformed: true); loggerProcess.stdout - .transform(utf8.decoder) + .transform(decoder) .transform(const LineSplitter()) .listen(_parseLogLine); loggerProcess.stderr - .transform(utf8.decoder) + .transform(decoder) .transform(const LineSplitter()) .listen(_parseLogLine); return loggerProcess.exitCode; diff --git a/packages/shorebird_cli/test/src/commands/preview_command_test.dart b/packages/shorebird_cli/test/src/commands/preview_command_test.dart index 118a6f68..6cd50ee1 100644 --- a/packages/shorebird_cli/test/src/commands/preview_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/preview_command_test.dart @@ -1,4 +1,4 @@ -// cspell:words libinfo networkd +// cspell:words libinfo networkd FFFD import 'dart:async'; import 'dart:convert'; import 'dart:io' hide Platform; @@ -1031,6 +1031,36 @@ channel: ${track.channel} verify(() => logger.err(output)).called(1); }); + test('handles non-UTF8 bytes in output without throwing', () async { + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer(setupAndroidShorebirdYaml); + + final completer = Completer(); + when(() => process.exitCode).thenAnswer((_) => completer.future); + // Create byte sequences with invalid UTF-8: 0xFF is not valid in UTF-8 + // "Hello" + 0xFF + "!" for stdout, "Error" + 0xFF + "!" for stderr + when( + () => process.stdout, + ).thenAnswer( + (_) => Stream.value([0x48, 0x65, 0x6C, 0x6C, 0x6F, 0xFF, 0x21]), + ); + when( + () => process.stderr, + ).thenAnswer( + (_) => Stream.value([0x45, 0x72, 0x72, 0x6F, 0x72, 0xFF, 0x21]), + ); + final result = runWithOverrides(command.run); + completer.complete(0); + await expectLater(await result, equals(ExitCode.success.code)); + // The invalid bytes should be replaced with the replacement character + verify(() => logger.info('Hello\uFFFD!')).called(1); + verify(() => logger.err('Error\uFFFD!')).called(1); + }); + group('when in a shorebird project without flavors', () { setUp(() { when( @@ -2022,6 +2052,35 @@ channel: ${DeploymentTrack.staging.channel} verify(() => logger.err('hello error')).called(1); }); }); + + test('handles non-UTF8 bytes in output without throwing', () async { + setupPreviewArtifact( + baseDirectory: Directory( + p.join( + previewDirectory.path, + 'linux_${releaseVersion}_$releaseArtifactId', + ), + ), + ); + // Create byte sequences with invalid UTF-8: 0xFF is not valid in UTF-8 + // "Hello" + 0xFF + "!" for stdout, "Error" + 0xFF + "!" for stderr + when( + () => process.stdout, + ).thenAnswer( + (_) => Stream.value([0x48, 0x65, 0x6C, 0x6C, 0x6F, 0xFF, 0x21]), + ); + when( + () => process.stderr, + ).thenAnswer( + (_) => Stream.value([0x45, 0x72, 0x72, 0x6F, 0x72, 0xFF, 0x21]), + ); + + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + // The invalid bytes should be replaced with the replacement character + verify(() => logger.info('Hello\uFFFD!')).called(1); + verify(() => logger.err('Error\uFFFD!')).called(1); + }); }); group('macos', () { @@ -2243,6 +2302,30 @@ channel: ${DeploymentTrack.staging.channel} ); }); }); + + group('non-UTF8 handling', () { + setUp(setupMacosShorebirdYaml); + + test('handles non-UTF8 bytes in log stream without throwing', () async { + // Create bytes with invalid UTF-8: 0xFF is not valid in UTF-8 + // Simulates a macOS log line with invalid bytes + final invalidUtf8Bytes = [ + ...utf8.encode( + '2025-01-31 09:53:22.889 Df app[22535:1d268] [shorebird] Hello', + ), + 0xFF, + 0x0A, // newline + ]; + when(() => open.newApplication(path: any(named: 'path'))).thenAnswer( + (_) async => Stream.value(invalidUtf8Bytes), + ); + + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + // The invalid byte should be replaced with the replacement character + verify(() => logger.info('[shorebird] Hello\uFFFD')).called(1); + }); + }); }); group('windows', () { @@ -2462,6 +2545,31 @@ channel: ${DeploymentTrack.staging.channel} verify(() => logger.err('hello error')).called(1); }); }); + + test('handles non-UTF8 bytes in output without throwing', () async { + File( + p.join(windowsReleaseDirectory.path, 'runner.exe'), + ).createSync(recursive: true); + createShorebirdYaml(); + // Create byte sequences with invalid UTF-8: 0xFF is not valid in UTF-8 + // "Hello" + 0xFF + "!" for stdout, "Error" + 0xFF + "!" for stderr + when( + () => process.stdout, + ).thenAnswer( + (_) => Stream.value([0x48, 0x65, 0x6C, 0x6C, 0x6F, 0xFF, 0x21]), + ); + when( + () => process.stderr, + ).thenAnswer( + (_) => Stream.value([0x45, 0x72, 0x72, 0x6F, 0x72, 0xFF, 0x21]), + ); + + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + // The invalid bytes should be replaced with the replacement character + verify(() => logger.info('Hello\uFFFD!')).called(1); + verify(() => logger.err('Error\uFFFD!')).called(1); + }); }); group('when no platform is specified', () { diff --git a/packages/shorebird_cli/test/src/executables/idevicesyslog_test.dart b/packages/shorebird_cli/test/src/executables/idevicesyslog_test.dart index 36555df8..1fab9645 100644 --- a/packages/shorebird_cli/test/src/executables/idevicesyslog_test.dart +++ b/packages/shorebird_cli/test/src/executables/idevicesyslog_test.dart @@ -1,3 +1,4 @@ +// cspell:words FFFD import 'dart:convert'; import 'dart:io'; @@ -149,6 +150,40 @@ Nov 10 17:58:47 kernel(Sandbox)[0] : Sandbox: Runner(52662) deny(1) iokit verify(() => logger.info('flutter: hello from stderr')).called(1); }, ); + + test('handles non-UTF8 bytes in output without throwing', () async { + // Create byte sequences with invalid UTF-8: 0xFF is not valid in UTF-8 + // "flutter: hello" + 0xFF for stdout, "flutter: error" + 0xFF for + // stderr + final stdoutBytes = [ + ...utf8.encode( + 'Nov 10 14:46:57 Runner(Flutter)[1044] : flutter: hello', + ), + 0xFF, + 0x0A, // newline + ]; + final stderrBytes = [ + ...utf8.encode( + 'Nov 10 14:46:57 Runner(Flutter)[1044] : flutter: error', + ), + 0xFF, + 0x0A, // newline + ]; + when( + () => loggerProcess.stdout, + ).thenAnswer((_) => Stream.value(stdoutBytes)); + when( + () => loggerProcess.stderr, + ).thenAnswer((_) => Stream.value(stderrBytes)); + + await runWithOverrides( + () => idevicesyslog.startLogger(device: device), + ); + + // The invalid bytes should be replaced with the replacement character + verify(() => logger.info('flutter: hello\uFFFD')).called(1); + verify(() => logger.info('flutter: error\uFFFD')).called(1); + }); }); }); }