Skip to content

Commit b4fc57d

Browse files
authored
Intercept UIScene device log and print a guided warning (flutter#181515)
This PR checks for iOS device warnings about UIScene and prints a message from UserMessages. As it stands now, it will not print anything since `uisceneMigrationWarning` is null. This will be overriden elsewhere. ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
1 parent 2391bdb commit b4fc57d

7 files changed

Lines changed: 279 additions & 55 deletions

File tree

packages/flutter_tools/lib/src/base/user_messages.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,4 +322,8 @@ class UserMessages {
322322
return '${baseUrl}android-setup';
323323
}
324324
}
325+
326+
/// Overridable message to be shown when detected from device logs that UIScene migration is
327+
/// still required.
328+
String? uiSceneMigrationWarning;
325329
}

packages/flutter_tools/lib/src/ios/core_devices.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ class IOSCoreDeviceControl {
327327
// * Don't ignore flutter logs:
328328
// 2025-09-16 12:50:07.953318-0500 Runner[1279:149305] flutter: ...
329329
RegExp(
330-
r'^\S* \S* \S*\[[0-9:]*] ((?!(\[INFO|\[WARNING|\[ERROR|\[IMPORTANT|\[FATAL):))(?!(flutter:)).*',
330+
r'^\S* \S* \S*\[[0-9:]*] ((?!(\[INFO|\[WARNING|\[ERROR|\[IMPORTANT|\[FATAL):))(?!(flutter:))(?!(\[UIKit App Config\] `UIScene` lifecycle)).*',
331331
),
332332
// Ignore iOS execution mode and potential error. This is not meaningful to the developer.
333333
// Example:

packages/flutter_tools/lib/src/ios/devices.dart

Lines changed: 129 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,12 @@ import 'xcode_build_settings.dart';
4747
import 'xcode_debug.dart';
4848
import 'xcodeproj.dart';
4949

50+
const kJITCrashLogInterceptorIdentifier = 'jit_crash_log';
5051
const kJITCrashFailureMessage =
5152
'Crash occurred when compiling unknown function in unoptimized JIT mode in unknown pass';
5253

5354
@visibleForTesting
54-
String jITCrashFailureInstructions(String deviceVersion) =>
55+
String jitCrashFailureInstructions(String deviceVersion) =>
5556
'''
5657
════════════════════════════════════════════════════════════════════════════════
5758
A change to iOS has caused a temporary break in Flutter's debug mode on
@@ -559,6 +560,15 @@ class IOSDevice extends Device {
559560
try {
560561
ProtocolDiscovery? vmServiceDiscovery;
561562
var installationResult = 1;
563+
564+
final DeviceLogReader deviceLogReader = getLogReader(
565+
app: package,
566+
usingCISystem: debuggingOptions.usingCISystem,
567+
);
568+
if (deviceLogReader is SharedIOSDeviceLogReader) {
569+
await _addLogInterceptors(deviceLogReader);
570+
}
571+
562572
if (debuggingOptions.debuggingEnabled) {
563573
_logger.printTrace('Debugging is enabled, connecting to vmService');
564574
vmServiceDiscovery = _setupDebuggerAndVmServiceDiscovery(
@@ -665,6 +675,7 @@ class IOSDevice extends Device {
665675
packageId: packageId,
666676
vmServiceDiscovery: vmServiceDiscovery,
667677
package: package,
678+
deviceLogReader: deviceLogReader,
668679
);
669680
} else if (isWirelesslyConnected) {
670681
// Wait for the Dart VM url to be discovered via logs (from `ios-deploy`)
@@ -824,6 +835,28 @@ class IOSDevice extends Device {
824835
_logger.printError('');
825836
}
826837

838+
Future<void> _addLogInterceptors(SharedIOSDeviceLogReader deviceLogReader) async {
839+
final String? uisceneWarning = globals.userMessages.uiSceneMigrationWarning;
840+
if (uisceneWarning != null) {
841+
final uisceneWarningInterceptor = LogInterceptor(
842+
identifier: 'uiscene_requirement',
843+
pattern: RegExp(
844+
'`UIScene` lifecycle will soon be required|This process does not adopt UIScene lifecycle',
845+
),
846+
action: () {
847+
globals.printWarning(uisceneWarning);
848+
},
849+
excludeFromStream: true,
850+
);
851+
deviceLogReader.addLogInterceptor(uisceneWarningInterceptor);
852+
}
853+
854+
final LogInterceptor? jitCrashInterceptor = await _jitCrashInterceptor();
855+
if (jitCrashInterceptor != null) {
856+
deviceLogReader.addLogInterceptor(jitCrashInterceptor);
857+
}
858+
}
859+
827860
/// Find the Dart VM url using ProtocolDiscovery (logs from `idevicesyslog`)
828861
/// and mDNS simultaneously, using whichever is found first. `idevicesyslog`
829862
/// does not work on wireless devices, so only use mDNS for wireless devices.
@@ -833,6 +866,7 @@ class IOSDevice extends Device {
833866
required DebuggingOptions debuggingOptions,
834867
ProtocolDiscovery? vmServiceDiscovery,
835868
IOSApp? package,
869+
required DeviceLogReader deviceLogReader,
836870
}) async {
837871
Timer? maxWaitForCI;
838872
final cancelCompleter = Completer<Uri?>();
@@ -874,11 +908,6 @@ class IOSDevice extends Device {
874908
});
875909
}
876910

877-
final StreamSubscription<String>? errorListener = await _interceptErrorsFromLogs(
878-
package,
879-
debuggingOptions: debuggingOptions,
880-
);
881-
882911
final bool discoverVMUrlFromLogs = vmServiceDiscovery != null && !isWirelesslyConnected;
883912

884913
// If mDNS fails, don't throw since url may still be findable through vmServiceDiscovery.
@@ -911,38 +940,30 @@ class IOSDevice extends Device {
911940
}
912941
}
913942
maxWaitForCI?.cancel();
914-
await errorListener?.cancel();
943+
if (deviceLogReader is SharedIOSDeviceLogReader) {
944+
deviceLogReader.removeLogInterceptorByIdentifier(kJITCrashLogInterceptorIdentifier);
945+
}
915946
return localUri;
916947
}
917948

918949
/// Listen to device logs for crash on iOS 18.4+ due to JIT restriction. If
919950
/// found, give guided error and throw tool exit. Returns null and does not
920951
/// listen if device is less than iOS 18.4.
921-
Future<StreamSubscription<String>?> _interceptErrorsFromLogs(
922-
IOSApp? package, {
923-
required DebuggingOptions debuggingOptions,
924-
}) async {
952+
Future<LogInterceptor?> _jitCrashInterceptor() async {
925953
// Currently only checking for kJITCrashFailureMessage, which only should
926954
// be checked on iOS 18.4+.
927955
if (sdkVersion == null || sdkVersion! < Version(18, 4, null)) {
928956
return null;
929957
}
930-
final DeviceLogReader deviceLogReader = getLogReader(
931-
app: package,
932-
usingCISystem: debuggingOptions.usingCISystem,
933-
);
934-
935-
final Stream<String> logStream = deviceLogReader.logLines;
936-
937958
final String deviceSdkVersion = await sdkNameAndVersion;
938-
939-
final StreamSubscription<String> errorListener = logStream.listen((String line) {
940-
if (line.contains(kJITCrashFailureMessage)) {
941-
throwToolExit(jITCrashFailureInstructions(deviceSdkVersion));
942-
}
943-
});
944-
945-
return errorListener;
959+
return LogInterceptor(
960+
identifier: kJITCrashLogInterceptorIdentifier,
961+
pattern: kJITCrashFailureMessage,
962+
action: () {
963+
throwToolExit(jitCrashFailureInstructions(deviceSdkVersion));
964+
},
965+
excludeFromStream: false,
966+
);
946967
}
947968

948969
ProtocolDiscovery _setupDebuggerAndVmServiceDiscovery({
@@ -957,7 +978,6 @@ class IOSDevice extends Device {
957978
app: package,
958979
usingCISystem: debuggingOptions.usingCISystem,
959980
);
960-
961981
// If the device supports syslog reading, prefer launching the app without
962982
// attaching the debugger to avoid the overhead of the unnecessary extra running process.
963983
if (majorSdkVersion >= IOSDeviceLogReader.minimumUniversalLoggingSdkVersion) {
@@ -1352,6 +1372,83 @@ String decodeSyslog(String line) {
13521372
}
13531373
}
13541374

1375+
/// When receiving logs from a device, a [LogInterceptor] can be used to match against a log and
1376+
/// perform an [action] if the [pattern] matches.
1377+
class LogInterceptor {
1378+
LogInterceptor({
1379+
required this.identifier,
1380+
required this.pattern,
1381+
required this.action,
1382+
required this.excludeFromStream,
1383+
});
1384+
1385+
/// Unique identifier to make for easy removal from a list.
1386+
final String identifier;
1387+
1388+
/// Logs will be checked to see if they contain the [pattern].
1389+
final Pattern pattern;
1390+
1391+
/// If the log contain the [pattern], the [action] will be called.
1392+
final void Function() action;
1393+
1394+
/// If `true`, the log will be excluded from being added to the stream.
1395+
final bool excludeFromStream;
1396+
}
1397+
1398+
/// Shared logic between iOS device log readers, such as [IOSDeviceLogReader]
1399+
/// for physical iOS devices and _IOSSimulatorLogReader for simulators.
1400+
abstract class SharedIOSDeviceLogReader extends DeviceLogReader {
1401+
@visibleForOverriding
1402+
/// [StreamController] for iOS device logs.
1403+
StreamController<String> get linesController;
1404+
1405+
/// Interceptors that should be checked with every log.
1406+
final List<LogInterceptor> _logInterceptors = [];
1407+
1408+
void addLogInterceptor(LogInterceptor interceptor) {
1409+
_logInterceptors.add(interceptor);
1410+
}
1411+
1412+
/// Once removed, the [LogInterceptor] will no longer intercept logs.
1413+
void removeLogInterceptor(LogInterceptor interceptor) {
1414+
_logInterceptors.remove(interceptor);
1415+
}
1416+
1417+
/// Remove where [LogInterceptor.identifier] matches [identifier]. Once removed, the
1418+
/// [LogInterceptor] will no longer intercept logs.
1419+
void removeLogInterceptorByIdentifier(String identifier) {
1420+
_logInterceptors.removeWhere((item) => item.identifier == identifier);
1421+
}
1422+
1423+
/// Checks if the [message] matches any [_logInterceptors] and performs the corresponding action
1424+
/// of the first matched interceptor.
1425+
///
1426+
/// Returns `true` if the log should be not added to the [StreamController] to be displayed to the user.
1427+
bool _interceptLog(String message) {
1428+
for (final LogInterceptor interceptor in _logInterceptors) {
1429+
if (message.contains(interceptor.pattern)) {
1430+
interceptor.action();
1431+
if (interceptor.excludeFromStream) {
1432+
return true;
1433+
}
1434+
return false;
1435+
}
1436+
}
1437+
return false;
1438+
}
1439+
1440+
/// Adds [message] to the [linesController] if the [StreamController] is open and the log is not
1441+
/// intercepted.
1442+
void addLogToStream(String message) {
1443+
// Sometimes (race condition?) we try to send a log after the controller has
1444+
// been closed. See https://github.com/flutter/flutter/issues/99021 for more
1445+
// context.
1446+
if (!linesController.isClosed && !_interceptLog(message)) {
1447+
linesController.add(message);
1448+
}
1449+
}
1450+
}
1451+
13551452
/// Listens to multiple logging sources to get the logs from the physical iOS device.
13561453
///
13571454
/// Potential logging sources include:
@@ -1364,7 +1461,7 @@ String decodeSyslog(String line) {
13641461
///
13651462
/// Logs are added to the [linesController] and consumed through the [logLines] stream by
13661463
/// [FlutterDevice.startEchoingDeviceLog].
1367-
class IOSDeviceLogReader extends DeviceLogReader {
1464+
class IOSDeviceLogReader extends SharedIOSDeviceLogReader {
13681465
IOSDeviceLogReader._(
13691466
this._xcode,
13701467
this._iMobileDevice,
@@ -1453,23 +1550,19 @@ class IOSDeviceLogReader extends DeviceLogReader {
14531550
// Logging from the dart code has no prefixing metadata.
14541551
final _debuggerLoggingRegex = RegExp(r'^\S* \S* \S*\[[0-9:]*] (.*)');
14551552

1553+
@override
14561554
@visibleForTesting
14571555
late final linesController = StreamController<String>.broadcast(
14581556
onListen: _listenToSysLog,
14591557
onCancel: dispose,
14601558
);
14611559

1462-
// Sometimes (race condition?) we try to send a log after the controller has
1463-
// been closed. See https://github.com/flutter/flutter/issues/99021 for more
1464-
// context.
14651560
@visibleForTesting
14661561
void addToLinesController(String message, IOSDeviceLogSource source) {
1467-
if (!linesController.isClosed) {
1468-
if (_excludeLog(message, source)) {
1469-
return;
1470-
}
1471-
linesController.add(message);
1562+
if (_excludeLog(message, source)) {
1563+
return;
14721564
}
1565+
addLogToStream(message);
14731566
}
14741567

14751568
/// Used to track messages prefixed with "flutter:" from the fallback log source.

packages/flutter_tools/lib/src/ios/simulators.dart

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import '../project.dart';
2929
import '../protocol_discovery.dart';
3030
import '../vmservice.dart';
3131
import 'application_package.dart';
32+
import 'devices.dart';
3233
import 'mac.dart';
3334
import 'plist_parser.dart';
3435

@@ -770,6 +771,8 @@ Future<Process> launchDeviceUnifiedLogging(IOSSimulator device, String? appName)
770771
'senderImagePath ENDSWITH "/Flutter"',
771772
'senderImagePath ENDSWITH "/libswiftCore.dylib"',
772773
'processImageUUID == senderImageUUID',
774+
'eventMessage CONTAINS "`UIScene` lifecycle will soon be required"',
775+
'eventMessage CONTAINS "This process does not adopt UIScene lifecycle."',
773776
]),
774777
// Filter out some messages that clearly aren't related to Flutter.
775778
notP('eventMessage CONTAINS ": could not find icon for representation -> com.apple."'),
@@ -808,7 +811,7 @@ Future<Process?> launchSystemLogTool(IOSSimulator device) async {
808811
return null;
809812
}
810813

811-
class _IOSSimulatorLogReader extends DeviceLogReader {
814+
class _IOSSimulatorLogReader extends SharedIOSDeviceLogReader {
812815
_IOSSimulatorLogReader(this.device, IOSApp? app) : _appName = app?.name?.replaceAll('.app', '');
813816

814817
final IOSSimulator device;
@@ -820,6 +823,10 @@ class _IOSSimulatorLogReader extends DeviceLogReader {
820823
onCancel: _stop,
821824
);
822825

826+
@override
827+
@visibleForTesting
828+
StreamController<String> get linesController => _linesController;
829+
823830
// We log from two files: the device and the system log.
824831
Process? _deviceProcess;
825832
Process? _systemProcess;
@@ -958,13 +965,13 @@ class _IOSSimulatorLogReader extends DeviceLogReader {
958965
int repeat = int.parse(multi.group(1)!);
959966
repeat = math.max(0, math.min(100, repeat));
960967
for (var i = 1; i < repeat; i++) {
961-
_linesController.add(_lastLine!);
968+
addLogToStream(_lastLine!);
962969
}
963970
}
964971
} else {
965972
_lastLine = _filterDeviceLine(line);
966973
if (_lastLine != null) {
967-
_linesController.add(_lastLine!);
974+
addLogToStream(_lastLine!);
968975
_lastLineMatched = true;
969976
} else {
970977
_lastLineMatched = false;
@@ -982,7 +989,7 @@ class _IOSSimulatorLogReader extends DeviceLogReader {
982989
try {
983990
final Object? decodedJson = jsonDecode(message);
984991
if (decodedJson is String) {
985-
_linesController.add(decodedJson);
992+
addLogToStream(decodedJson);
986993
}
987994
} on FormatException {
988995
globals.printError('Logger returned non-JSON response: $message');
@@ -1003,7 +1010,7 @@ class _IOSSimulatorLogReader extends DeviceLogReader {
10031010

10041011
final String filteredLine = _filterSystemLog(line);
10051012

1006-
_linesController.add(filteredLine);
1013+
addLogToStream(filteredLine);
10071014
}
10081015

10091016
void _stop() {

packages/flutter_tools/test/general.shard/ios/core_devices_test.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1902,6 +1902,7 @@ Waiting for the application to terminate...
19021902
2025-09-16 12:15:47.939171-0500 Runner[1230:133819] This log happens after the application is launched but matches an ignore pattern and should be skipped
19031903
This log happens after the application is launched and should be sent to FakeIOSCoreDeviceLogForwarder
19041904
2025-09-16 12:15:47.939171-0500 Runner[1230:133819] flutter: This log happens after the application is launched and should be sent to FakeIOSCoreDeviceLogForwarder
1905+
2026-01-26 16:12:19.095287-0600 Runner[2236:2107639] [UIKit App Config] `UIScene` lifecycle will soon be required. Failure to adopt will result in an assert in the future.
19051906
2025-09-16 12:15:47.939171-0500 Runner[1230:133819] [INFO:flutter/runtime/service_protocol.cc(121)] This log happens after the application is launched and should be sent to FakeIOSCoreDeviceLogForwarder
19061907
''',
19071908
),
@@ -1919,13 +1920,14 @@ This log happens after the application is launched and should be sent to FakeIOS
19191920
expect(fakeProcessManager, hasNoRemainingExpectations);
19201921
expect(shutdownHooks.registeredHooks.length, 1);
19211922
expect(logger.errorText, isEmpty);
1922-
expect(logForwarder.logs.length, 3);
1923+
expect(logForwarder.logs.length, 4);
19231924
expect(
19241925
logForwarder.logs,
19251926
containsAll([
19261927
'This log happens after the application is launched and should be sent to FakeIOSCoreDeviceLogForwarder',
19271928
'2025-09-16 12:15:47.939171-0500 Runner[1230:133819] flutter: This log happens after the application is launched and should be sent to FakeIOSCoreDeviceLogForwarder',
19281929
'2025-09-16 12:15:47.939171-0500 Runner[1230:133819] [INFO:flutter/runtime/service_protocol.cc(121)] This log happens after the application is launched and should be sent to FakeIOSCoreDeviceLogForwarder',
1930+
'2026-01-26 16:12:19.095287-0600 Runner[2236:2107639] [UIKit App Config] `UIScene` lifecycle will soon be required. Failure to adopt will result in an assert in the future.',
19291931
]),
19301932
);
19311933
expect(

0 commit comments

Comments
 (0)