1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:async';
6
7import 'package:meta/meta.dart';
8import 'package:process/process.dart';
9import 'package:unified_analytics/unified_analytics.dart';
10import 'package:vm_service/vm_service.dart' as vm_service;
11
12import '../application_package.dart';
13import '../base/common.dart';
14import '../base/file_system.dart';
15import '../base/io.dart';
16import '../base/logger.dart';
17import '../base/os.dart';
18import '../base/platform.dart';
19import '../base/process.dart';
20import '../base/utils.dart';
21import '../base/version.dart';
22import '../build_info.dart';
23import '../convert.dart';
24import '../darwin/darwin.dart';
25import '../device.dart';
26import '../device_port_forwarder.dart';
27import '../device_vm_service_discovery_for_attach.dart';
28import '../features.dart';
29import '../globals.dart' as globals;
30import '../macos/xcdevice.dart';
31import '../mdns_discovery.dart';
32import '../project.dart';
33import '../protocol_discovery.dart';
34import '../vmservice.dart';
35import 'application_package.dart';
36import 'core_devices.dart';
37import 'ios_deploy.dart';
38import 'ios_workflow.dart';
39import 'iproxy.dart';
40import 'mac.dart';
41import 'xcode_build_settings.dart';
42import 'xcode_debug.dart';
43import 'xcodeproj.dart';
44
45const kJITCrashFailureMessage =
46 'Crash occurred when compiling unknown function in unoptimized JIT mode in unknown pass';
47
48@visibleForTesting
49String jITCrashFailureInstructions(String deviceVersion) =>
50 '''
51════════════════════════════════════════════════════════════════════════════════
52A change to iOS has caused a temporary break in Flutter's debug mode on
53physical devices.
54See https://github.com/flutter/flutter/issues/163984 for details.
55
56In the meantime, we recommend these temporary workarounds:
57
58* When developing with a physical device, use one running iOS 18.3 or lower.
59* Use a simulator for development rather than a physical device.
60* If you must use a device updated to $deviceVersion, use Flutter's release or
61 profile mode via --release or --profile flags.
62════════════════════════════════════════════════════════════════════════════════''';
63
64enum IOSDeploymentMethod {
65 iosDeployLaunch,
66 iosDeployLaunchAndAttach,
67 coreDeviceWithoutDebugger,
68 coreDeviceWithLLDB,
69 coreDeviceWithXcode,
70 coreDeviceWithXcodeFallback,
71}
72
73class IOSDevices extends PollingDeviceDiscovery {
74 IOSDevices({
75 required Platform platform,
76 required this.xcdevice,
77 required IOSWorkflow iosWorkflow,
78 required Logger logger,
79 }) : _platform = platform,
80 _iosWorkflow = iosWorkflow,
81 _logger = logger,
82 super('iOS devices');
83
84 final Platform _platform;
85 final IOSWorkflow _iosWorkflow;
86 final Logger _logger;
87
88 @visibleForTesting
89 final XCDevice xcdevice;
90
91 @override
92 bool get supportsPlatform => _platform.isMacOS;
93
94 @override
95 bool get canListAnything => _iosWorkflow.canListDevices;
96
97 @override
98 bool get requiresExtendedWirelessDeviceDiscovery => true;
99
100 StreamSubscription<XCDeviceEventNotification>? _observedDeviceEventsSubscription;
101
102 /// Cache for all devices found by `xcdevice list`, including not connected
103 /// devices. Used to minimize the need to call `xcdevice list`.
104 ///
105 /// Separate from `deviceNotifier` since `deviceNotifier` should only contain
106 /// connected devices.
107 final _cachedPolledDevices = <String, IOSDevice>{};
108
109 /// Maps device id to a map of the device's observed connections. When the
110 /// mapped connection is `true`, that means that observed events indicated
111 /// the device is connected via that particular interface.
112 ///
113 /// The device id must be missing from the map or both interfaces must be
114 /// false for the device to be considered disconnected.
115 ///
116 /// Example:
117 /// {
118 /// device-id: {
119 /// usb: false,
120 /// wifi: false,
121 /// },
122 /// }
123 final _observedConnectionsByDeviceId = <String, Map<XCDeviceEventInterface, bool>>{};
124
125 @override
126 Future<void> startPolling() async {
127 if (!_platform.isMacOS) {
128 throw UnsupportedError('Control of iOS devices or simulators only supported on macOS.');
129 }
130 if (!xcdevice.isInstalled) {
131 return;
132 }
133
134 // Start by populating all currently attached devices.
135 _updateCachedDevices(await pollingGetDevices());
136 _updateNotifierFromCache();
137
138 // cancel any outstanding subscriptions.
139 await _observedDeviceEventsSubscription?.cancel();
140 _observedDeviceEventsSubscription = xcdevice.observedDeviceEvents()?.listen(
141 onDeviceEvent,
142 onError: (Object error, StackTrace stack) {
143 _logger.printTrace('Process exception running xcdevice observe:\n$error\n$stack');
144 },
145 onDone: () {
146 // If xcdevice is killed or otherwise dies, polling will be stopped.
147 // No retry is attempted and the polling client will have to restart polling
148 // (restart the IDE). Avoid hammering on a process that is
149 // continuously failing.
150 _logger.printTrace('xcdevice observe stopped');
151 },
152 cancelOnError: true,
153 );
154 }
155
156 @visibleForTesting
157 Future<void> onDeviceEvent(XCDeviceEventNotification event) async {
158 final ItemListNotifier<Device> notifier = deviceNotifier;
159
160 Device? knownDevice;
161 for (final Device device in notifier.items) {
162 if (device.id == event.deviceIdentifier) {
163 knownDevice = device;
164 }
165 }
166
167 final Map<XCDeviceEventInterface, bool> deviceObservedConnections =
168 _observedConnectionsByDeviceId[event.deviceIdentifier] ??
169 <XCDeviceEventInterface, bool>{
170 XCDeviceEventInterface.usb: false,
171 XCDeviceEventInterface.wifi: false,
172 };
173
174 if (event.eventType == XCDeviceEvent.attach) {
175 // Update device's observed connections.
176 deviceObservedConnections[event.eventInterface] = true;
177 _observedConnectionsByDeviceId[event.deviceIdentifier] = deviceObservedConnections;
178
179 // If device was not already in notifier, add it.
180 if (knownDevice == null) {
181 if (_cachedPolledDevices[event.deviceIdentifier] == null) {
182 // If device is not found in cache, there's no way to get details
183 // for an individual attached device, so repopulate them all.
184 _updateCachedDevices(await pollingGetDevices());
185 }
186 _updateNotifierFromCache();
187 }
188 } else {
189 // Update device's observed connections.
190 deviceObservedConnections[event.eventInterface] = false;
191 _observedConnectionsByDeviceId[event.deviceIdentifier] = deviceObservedConnections;
192
193 // If device is in the notifier and does not have other observed
194 // connections, remove it.
195 if (knownDevice != null && !_deviceHasObservedConnection(deviceObservedConnections)) {
196 notifier.removeItem(knownDevice);
197 }
198 }
199 }
200
201 /// Adds or updates devices in cache. Does not remove devices from cache.
202 void _updateCachedDevices(List<Device> devices) {
203 for (final device in devices) {
204 if (device is! IOSDevice) {
205 continue;
206 }
207 _cachedPolledDevices[device.id] = device;
208 }
209 }
210
211 /// Updates notifier with devices found in the cache that are determined
212 /// to be connected.
213 void _updateNotifierFromCache() {
214 final ItemListNotifier<Device> notifier = deviceNotifier;
215
216 // Device is connected if it has either an observed usb or wifi connection
217 // or it has not been observed but was found as connected in the cache.
218 final List<Device> connectedDevices = _cachedPolledDevices.values.where((Device device) {
219 final Map<XCDeviceEventInterface, bool>? deviceObservedConnections =
220 _observedConnectionsByDeviceId[device.id];
221 return (deviceObservedConnections != null &&
222 _deviceHasObservedConnection(deviceObservedConnections)) ||
223 (deviceObservedConnections == null && device.isConnected);
224 }).toList();
225
226 notifier.updateWithNewList(connectedDevices);
227 }
228
229 bool _deviceHasObservedConnection(Map<XCDeviceEventInterface, bool> deviceObservedConnections) {
230 return (deviceObservedConnections[XCDeviceEventInterface.usb] ?? false) ||
231 (deviceObservedConnections[XCDeviceEventInterface.wifi] ?? false);
232 }
233
234 @override
235 Future<void> stopPolling() async {
236 await _observedDeviceEventsSubscription?.cancel();
237 }
238
239 @override
240 Future<List<Device>> pollingGetDevices({Duration? timeout}) async {
241 if (!_platform.isMacOS) {
242 throw UnsupportedError('Control of iOS devices or simulators only supported on macOS.');
243 }
244
245 return xcdevice.getAvailableIOSDevices(timeout: timeout);
246 }
247
248 Future<Device?> waitForDeviceToConnect(IOSDevice device, Logger logger) async {
249 final XCDeviceEventNotification? eventDetails = await xcdevice.waitForDeviceToConnect(
250 device.id,
251 );
252
253 if (eventDetails != null) {
254 device.isConnected = true;
255 device.connectionInterface = eventDetails.eventInterface.connectionInterface;
256 return device;
257 }
258 return null;
259 }
260
261 void cancelWaitForDeviceToConnect() {
262 xcdevice.cancelWaitForDeviceToConnect();
263 }
264
265 @override
266 Future<List<String>> getDiagnostics() async {
267 if (!_platform.isMacOS) {
268 return const <String>['Control of iOS devices or simulators only supported on macOS.'];
269 }
270
271 return xcdevice.getDiagnostics();
272 }
273
274 @override
275 List<String> get wellKnownIds => const <String>[];
276}
277
278class IOSDevice extends Device {
279 IOSDevice(
280 super.id, {
281 required FileSystem fileSystem,
282 required this.name,
283 required this.cpuArchitecture,
284 required this.connectionInterface,
285 required this.isConnected,
286 required this.isPaired,
287 required this.devModeEnabled,
288 required this.isCoreDevice,
289 String? sdkVersion,
290 required Platform platform,
291 required IOSDeploy iosDeploy,
292 required IMobileDevice iMobileDevice,
293 required IOSCoreDeviceControl coreDeviceControl,
294 required IOSCoreDeviceLauncher coreDeviceLauncher,
295 required XcodeDebug xcodeDebug,
296 required IProxy iProxy,
297 required super.logger,
298 required Analytics analytics,
299 }) : _sdkVersion = sdkVersion,
300 _iosDeploy = iosDeploy,
301 _iMobileDevice = iMobileDevice,
302 _coreDeviceControl = coreDeviceControl,
303 _coreDeviceLauncher = coreDeviceLauncher,
304 _xcodeDebug = xcodeDebug,
305 _iproxy = iProxy,
306 _fileSystem = fileSystem,
307 _logger = logger,
308 _analytics = analytics,
309 _platform = platform,
310 super(category: Category.mobile, platformType: PlatformType.ios, ephemeral: true) {
311 if (!_platform.isMacOS) {
312 assert(false, 'Control of iOS devices or simulators only supported on Mac OS.');
313 return;
314 }
315 }
316
317 final String? _sdkVersion;
318 final IOSDeploy _iosDeploy;
319 final Analytics _analytics;
320 final FileSystem _fileSystem;
321 final Logger _logger;
322 final Platform _platform;
323 final IMobileDevice _iMobileDevice;
324 final IOSCoreDeviceControl _coreDeviceControl;
325 final IOSCoreDeviceLauncher _coreDeviceLauncher;
326 final XcodeDebug _xcodeDebug;
327 final IProxy _iproxy;
328
329 Version? get sdkVersion {
330 return Version.parse(_sdkVersion);
331 }
332
333 /// May be 0 if version cannot be parsed.
334 int get majorSdkVersion {
335 return sdkVersion?.major ?? 0;
336 }
337
338 @override
339 final String name;
340
341 @override
342 bool supportsRuntimeMode(BuildMode buildMode) => buildMode != BuildMode.jitRelease;
343
344 final DarwinArch cpuArchitecture;
345
346 @override
347 /// The [connectionInterface] provided from `XCDevice.getAvailableIOSDevices`
348 /// may not be accurate. Sometimes if it doesn't have a long enough time
349 /// to connect, wireless devices will have an interface of `usb`/`attached`.
350 /// This may change after waiting for the device to connect in
351 /// `waitForDeviceToConnect`.
352 DeviceConnectionInterface connectionInterface;
353
354 @override
355 bool isConnected;
356
357 var devModeEnabled = false;
358
359 /// Device has trusted this computer and paired.
360 var isPaired = false;
361
362 /// CoreDevice is a device connectivity stack introduced in Xcode 15. Devices
363 /// with iOS 17 or greater are CoreDevices.
364 final bool isCoreDevice;
365
366 final _logReaders = <IOSApp?, DeviceLogReader>{};
367
368 DevicePortForwarder? _portForwarder;
369
370 @visibleForTesting
371 IOSDeployDebugger? iosDeployDebugger;
372
373 @override
374 Future<bool> get isLocalEmulator async => false;
375
376 @override
377 Future<String?> get emulatorId async => null;
378
379 @override
380 bool get supportsStartPaused => false;
381
382 @override
383 bool get supportsFlavors => true;
384
385 @override
386 Future<bool> isAppInstalled(ApplicationPackage app, {String? userIdentifier}) async {
387 bool result;
388 try {
389 if (isCoreDevice) {
390 result = await _coreDeviceControl.isAppInstalled(bundleId: app.id, deviceId: id);
391 } else {
392 result = await _iosDeploy.isAppInstalled(bundleId: app.id, deviceId: id);
393 }
394 } on ProcessException catch (e) {
395 _logger.printError(e.message);
396 return false;
397 }
398 return result;
399 }
400
401 @override
402 Future<bool> isLatestBuildInstalled(ApplicationPackage app) async => false;
403
404 @override
405 Future<bool> installApp(covariant IOSApp app, {String? userIdentifier}) async {
406 final Directory bundle = _fileSystem.directory(app.deviceBundlePath);
407 if (!bundle.existsSync()) {
408 _logger.printError(
409 'Could not find application bundle at ${bundle.path}; have you run "flutter build ios"?',
410 );
411 return false;
412 }
413
414 int installationResult;
415 try {
416 if (isCoreDevice) {
417 installationResult =
418 await _coreDeviceControl.installApp(deviceId: id, bundlePath: bundle.path) ? 0 : 1;
419 } else {
420 installationResult = await _iosDeploy.installApp(
421 deviceId: id,
422 bundlePath: bundle.path,
423 appDeltaDirectory: app.appDeltaDirectory,
424 launchArguments: <String>[],
425 interfaceType: connectionInterface,
426 );
427 }
428 } on ProcessException catch (e) {
429 _logger.printError(e.message);
430 return false;
431 }
432 if (installationResult != 0) {
433 _logger.printError('Could not install ${bundle.path} on $id.');
434 _logger.printError('Try launching Xcode and selecting "Product > Run" to fix the problem:');
435 _logger.printError(' open ios/Runner.xcworkspace');
436 _logger.printError('');
437 return false;
438 }
439 return true;
440 }
441
442 @override
443 Future<bool> uninstallApp(ApplicationPackage app, {String? userIdentifier}) async {
444 int uninstallationResult;
445 try {
446 if (isCoreDevice) {
447 uninstallationResult = await _coreDeviceControl.uninstallApp(deviceId: id, bundleId: app.id)
448 ? 0
449 : 1;
450 } else {
451 uninstallationResult = await _iosDeploy.uninstallApp(deviceId: id, bundleId: app.id);
452 }
453 } on ProcessException catch (e) {
454 _logger.printError(e.message);
455 return false;
456 }
457 if (uninstallationResult != 0) {
458 _logger.printError('Could not uninstall ${app.id} on $id.');
459 return false;
460 }
461 return true;
462 }
463
464 @override
465 // 32-bit devices are not supported.
466 Future<bool> isSupported() async => cpuArchitecture == DarwinArch.arm64;
467
468 @override
469 Future<LaunchResult> startApp(
470 IOSApp package, {
471 String? mainPath,
472 String? route,
473 required DebuggingOptions debuggingOptions,
474 Map<String, Object?> platformArgs = const <String, Object?>{},
475 bool prebuiltApplication = false,
476 String? userIdentifier,
477 @visibleForTesting Duration? discoveryTimeout,
478 @visibleForTesting ShutdownHooks? shutdownHooks,
479 }) async {
480 String? packageId;
481 if (isWirelesslyConnected &&
482 debuggingOptions.debuggingEnabled &&
483 debuggingOptions.disablePortPublication) {
484 throwToolExit(
485 'Cannot start app on wirelessly tethered iOS device. Try running again with the --publish-port flag',
486 );
487 }
488
489 if (!prebuiltApplication) {
490 _logger.printTrace('Building ${package.name} for $id');
491
492 // Step 1: Build the precompiled/DBC application if necessary.
493 final XcodeBuildResult buildResult = await buildXcodeProject(
494 app: package as BuildableIOSApp,
495 buildInfo: debuggingOptions.buildInfo,
496 targetOverride: mainPath,
497 activeArch: cpuArchitecture,
498 deviceID: id,
499 disablePortPublication:
500 debuggingOptions.usingCISystem && debuggingOptions.disablePortPublication,
501 );
502 if (!buildResult.success) {
503 _logger.printError('Could not build the precompiled application for the device.');
504 await diagnoseXcodeBuildFailure(
505 buildResult,
506 analytics: _analytics,
507 fileSystem: globals.fs,
508 logger: globals.logger,
509 platform: FlutterDarwinPlatform.ios,
510 project: package.project.parent,
511 );
512 _logger.printError('');
513 return LaunchResult.failed();
514 }
515 packageId = buildResult.xcodeBuildExecution?.buildSettings[IosProject.kProductBundleIdKey];
516 }
517
518 packageId ??= package.id;
519
520 // Step 2: Check that the application exists at the specified path.
521 final Directory bundle = _fileSystem.directory(package.deviceBundlePath);
522 if (!bundle.existsSync()) {
523 _logger.printError('Could not find the built application bundle at ${bundle.path}.');
524 return LaunchResult.failed();
525 }
526
527 // Step 3: Attempt to install the application on the device.
528 final List<String> launchArguments = debuggingOptions.getIOSLaunchArguments(
529 EnvironmentType.physical,
530 route,
531 platformArgs,
532 interfaceType: connectionInterface,
533 );
534 Status startAppStatus = _logger.startProgress('Installing and launching...');
535
536 IOSDeploymentMethod? deploymentMethod;
537 try {
538 ProtocolDiscovery? vmServiceDiscovery;
539 var installationResult = 1;
540 if (debuggingOptions.debuggingEnabled) {
541 _logger.printTrace('Debugging is enabled, connecting to vmService');
542 vmServiceDiscovery = _setupDebuggerAndVmServiceDiscovery(
543 package: package,
544 bundle: bundle,
545 debuggingOptions: debuggingOptions,
546 launchArguments: launchArguments,
547 uninstallFirst: debuggingOptions.uninstallFirst,
548 );
549 }
550
551 if (isCoreDevice) {
552 final (
553 bool result,
554 IOSDeploymentMethod coreDeviceDeploymentMethod,
555 ) = await _startAppOnCoreDevice(
556 debuggingOptions: debuggingOptions,
557 package: package,
558 launchArguments: launchArguments,
559 mainPath: mainPath,
560 discoveryTimeout: discoveryTimeout,
561 shutdownHooks: shutdownHooks ?? globals.shutdownHooks,
562 );
563 installationResult = result ? 0 : 1;
564 deploymentMethod = coreDeviceDeploymentMethod;
565 } else if (iosDeployDebugger == null) {
566 deploymentMethod = IOSDeploymentMethod.iosDeployLaunch;
567 installationResult = await _iosDeploy.launchApp(
568 deviceId: id,
569 bundlePath: bundle.path,
570 appDeltaDirectory: package.appDeltaDirectory,
571 launchArguments: launchArguments,
572 interfaceType: connectionInterface,
573 uninstallFirst: debuggingOptions.uninstallFirst,
574 );
575 } else {
576 deploymentMethod = IOSDeploymentMethod.iosDeployLaunchAndAttach;
577 installationResult = await iosDeployDebugger!.launchAndAttach() ? 0 : 1;
578 }
579 if (installationResult != 0) {
580 _analytics.send(
581 Event.appleUsageEvent(
582 workflow: 'ios-physical-deployment',
583 parameter: deploymentMethod.name,
584 result: 'launch failed',
585 ),
586 );
587 _printInstallError(bundle);
588 await dispose();
589 return LaunchResult.failed();
590 }
591
592 if (!debuggingOptions.debuggingEnabled) {
593 _analytics.send(
594 Event.appleUsageEvent(
595 workflow: 'ios-physical-deployment',
596 parameter: deploymentMethod.name,
597 result: 'release success',
598 ),
599 );
600 return LaunchResult.succeeded();
601 }
602
603 _logger.printTrace('Application launched on the device. Waiting for Dart VM Service url.');
604
605 final int defaultTimeout;
606 if (isCoreDevice && debuggingOptions.debuggingEnabled) {
607 // Core devices with debugging enabled takes longer because this
608 // includes time to install and launch the app on the device.
609 defaultTimeout = isWirelesslyConnected ? 75 : 60;
610 } else if (isWirelesslyConnected) {
611 defaultTimeout = 45;
612 } else {
613 defaultTimeout = 30;
614 }
615
616 final timer = Timer(discoveryTimeout ?? Duration(seconds: defaultTimeout), () {
617 _logger.printError(
618 'The Dart VM Service was not discovered after $defaultTimeout seconds. This is taking much longer than expected...',
619 );
620 // If debugging with a wireless device and the timeout is reached, remind the
621 // user to allow local network permissions.
622 if (isWirelesslyConnected) {
623 _logger.printError(
624 '\nYour debugging device seems wirelessly connected. '
625 'Consider plugging it in and trying again.',
626 );
627 _logger.printError(
628 '\nClick "Allow" to the prompt asking if you would like to find and connect devices on your local network. '
629 'This is required for wireless debugging. If you selected "Don\'t Allow", '
630 'you can turn it on in Settings > Your App Name > Local Network. '
631 "If you don't see your app in the Settings, uninstall the app and rerun to see the prompt again.",
632 );
633 } else {
634 iosDeployDebugger?.checkForSymbolsFiles(_fileSystem);
635 iosDeployDebugger?.pauseDumpBacktraceResume();
636 }
637 });
638
639 Uri? localUri;
640 if (isCoreDevice) {
641 localUri = await _discoverDartVMForCoreDevice(
642 debuggingOptions: debuggingOptions,
643 packageId: packageId,
644 vmServiceDiscovery: vmServiceDiscovery,
645 package: package,
646 );
647 } else if (isWirelesslyConnected) {
648 // Wait for the Dart VM url to be discovered via logs (from `ios-deploy`)
649 // in ProtocolDiscovery. Then via mDNS, construct the Dart VM url using
650 // the device IP as the host by finding Dart VM services matching the
651 // app bundle id and Dart VM port.
652
653 // Wait for Dart VM Service to start up.
654 final Uri? serviceURL = await vmServiceDiscovery?.uri;
655 if (serviceURL == null) {
656 await iosDeployDebugger?.stopAndDumpBacktrace();
657 await dispose();
658 _analytics.send(
659 Event.appleUsageEvent(
660 workflow: 'ios-physical-deployment',
661 parameter: deploymentMethod.name,
662 result: 'wireless debugging failed',
663 ),
664 );
665 return LaunchResult.failed();
666 }
667
668 // If Dart VM Service URL with the device IP is not found within 5 seconds,
669 // change the status message to prompt users to click Allow. Wait 5 seconds because it
670 // should only show this message if they have not already approved the permissions.
671 // MDnsVmServiceDiscovery usually takes less than 5 seconds to find it.
672 final mDNSLookupTimer = Timer(const Duration(seconds: 5), () {
673 startAppStatus.stop();
674 startAppStatus = _logger.startProgress(
675 'Waiting for approval of local network permissions...',
676 );
677 });
678
679 // Get Dart VM Service URL with the device IP as the host.
680 localUri = await MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch(
681 packageId,
682 this,
683 usesIpv6: debuggingOptions.ipv6,
684 deviceVmservicePort: serviceURL.port,
685 useDeviceIPAsHost: true,
686 );
687
688 mDNSLookupTimer.cancel();
689 } else {
690 localUri = await vmServiceDiscovery?.uri;
691 // If the `ios-deploy` debugger loses connection before it finds the
692 // Dart Service VM url, try starting the debugger and launching the
693 // app again.
694 if (localUri == null &&
695 debuggingOptions.usingCISystem &&
696 iosDeployDebugger != null &&
697 iosDeployDebugger!.lostConnection) {
698 _logger.printStatus('Lost connection to device. Trying to connect again...');
699 await dispose();
700 vmServiceDiscovery = _setupDebuggerAndVmServiceDiscovery(
701 package: package,
702 bundle: bundle,
703 debuggingOptions: debuggingOptions,
704 launchArguments: launchArguments,
705 uninstallFirst: false,
706 skipInstall: true,
707 );
708 installationResult = await iosDeployDebugger!.launchAndAttach() ? 0 : 1;
709 if (installationResult != 0) {
710 _printInstallError(bundle);
711 await dispose();
712 return LaunchResult.failed();
713 }
714 localUri = await vmServiceDiscovery.uri;
715 }
716 }
717 timer.cancel();
718 if (localUri == null) {
719 await iosDeployDebugger?.stopAndDumpBacktrace();
720 await dispose();
721 _analytics.send(
722 Event.appleUsageEvent(
723 workflow: 'ios-physical-deployment',
724 parameter: deploymentMethod.name,
725 result: 'debugging failed',
726 ),
727 );
728 return LaunchResult.failed();
729 }
730 _analytics.send(
731 Event.appleUsageEvent(
732 workflow: 'ios-physical-deployment',
733 parameter: deploymentMethod.name,
734 result: 'debugging success',
735 ),
736 );
737 return LaunchResult.succeeded(vmServiceUri: localUri);
738 } on ProcessException catch (e) {
739 await iosDeployDebugger?.stopAndDumpBacktrace();
740 _logger.printError(e.message);
741 await dispose();
742 if (deploymentMethod != null) {
743 _analytics.send(
744 Event.appleUsageEvent(
745 workflow: 'ios-physical-deployment',
746 parameter: deploymentMethod.name,
747 result: 'process exception',
748 ),
749 );
750 }
751 return LaunchResult.failed();
752 } finally {
753 startAppStatus.stop();
754
755 if (isCoreDevice && debuggingOptions.debuggingEnabled && package is BuildableIOSApp) {
756 // When debugging via Xcode, after the app launches, reset the Generated
757 // settings to not include the custom configuration build directory.
758 // This is to prevent confusion if the project is later ran via Xcode
759 // rather than the Flutter CLI.
760 await updateGeneratedXcodeProperties(
761 project: FlutterProject.current(),
762 buildInfo: debuggingOptions.buildInfo,
763 targetOverride: mainPath,
764 );
765 }
766 }
767 }
768
769 void _printInstallError(Directory bundle) {
770 _logger.printError('Could not run ${bundle.path} on $id.');
771 _logger.printError('Try launching Xcode and selecting "Product > Run" to fix the problem:');
772 _logger.printError(' open ios/Runner.xcworkspace');
773 _logger.printError('');
774 }
775
776 /// Find the Dart VM url using ProtocolDiscovery (logs from `idevicesyslog`)
777 /// and mDNS simultaneously, using whichever is found first. `idevicesyslog`
778 /// does not work on wireless devices, so only use mDNS for wireless devices.
779 /// Wireless devices require using the device IP as the host.
780 Future<Uri?> _discoverDartVMForCoreDevice({
781 required String packageId,
782 required DebuggingOptions debuggingOptions,
783 ProtocolDiscovery? vmServiceDiscovery,
784 IOSApp? package,
785 }) async {
786 Timer? maxWaitForCI;
787 final cancelCompleter = Completer<Uri?>();
788
789 // When testing in CI, wait a max of 10 minutes for the Dart VM to be found.
790 // Afterwards, stop the app from running and upload DerivedData Logs to debug
791 // logs directory. CoreDevices are run through Xcode and launch logs are
792 // therefore found in DerivedData.
793 if (debuggingOptions.usingCISystem && debuggingOptions.debugLogsDirectoryPath != null) {
794 maxWaitForCI = Timer(const Duration(minutes: 10), () async {
795 _logger.printError('Failed to find Dart VM after 10 minutes.');
796 await _xcodeDebug.exit();
797 final String? homePath = _platform.environment['HOME'];
798 Directory? derivedData;
799 if (homePath != null) {
800 derivedData = _fileSystem.directory(
801 _fileSystem.path.join(homePath, 'Library', 'Developer', 'Xcode', 'DerivedData'),
802 );
803 }
804 if (derivedData != null && derivedData.existsSync()) {
805 final Directory debugLogsDirectory = _fileSystem.directory(
806 debuggingOptions.debugLogsDirectoryPath,
807 );
808 debugLogsDirectory.createSync(recursive: true);
809 for (final FileSystemEntity entity in derivedData.listSync()) {
810 if (entity is! Directory || !entity.childDirectory('Logs').existsSync()) {
811 continue;
812 }
813 final Directory logsToCopy = entity.childDirectory('Logs');
814 final Directory copyDestination = debugLogsDirectory
815 .childDirectory('DerivedDataLogs')
816 .childDirectory(entity.basename)
817 .childDirectory('Logs');
818 _logger.printTrace('Copying logs ${logsToCopy.path} to ${copyDestination.path}...');
819 copyDirectory(logsToCopy, copyDestination);
820 }
821 }
822 cancelCompleter.complete();
823 });
824 }
825
826 final StreamSubscription<String>? errorListener = await _interceptErrorsFromLogs(
827 package,
828 debuggingOptions: debuggingOptions,
829 );
830
831 final bool discoverVMUrlFromLogs = vmServiceDiscovery != null && !isWirelesslyConnected;
832
833 // If mDNS fails, don't throw since url may still be findable through vmServiceDiscovery.
834 final Future<Uri?> vmUrlFromMDns = MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch(
835 packageId,
836 this,
837 usesIpv6: debuggingOptions.ipv6,
838 useDeviceIPAsHost: isWirelesslyConnected,
839 throwOnMissingLocalNetworkPermissionsError: !discoverVMUrlFromLogs,
840 );
841
842 final discoveryOptions = <Future<Uri?>>[
843 vmUrlFromMDns,
844 // vmServiceDiscovery uses device logs (`idevicesyslog`), which doesn't work
845 // on wireless devices.
846 if (discoverVMUrlFromLogs) vmServiceDiscovery.uri,
847 ];
848
849 Uri? localUri = await Future.any(<Future<Uri?>>[...discoveryOptions, cancelCompleter.future]);
850
851 // If the first future to return is null, wait for the other to complete
852 // unless canceled.
853 if (localUri == null && !cancelCompleter.isCompleted) {
854 final Future<List<Uri?>> allDiscoveryOptionsComplete = Future.wait(discoveryOptions);
855 await Future.any(<Future<Object?>>[allDiscoveryOptionsComplete, cancelCompleter.future]);
856 if (!cancelCompleter.isCompleted) {
857 // If it wasn't cancelled, that means one of the discovery options completed.
858 final List<Uri?> vmUrls = await allDiscoveryOptionsComplete;
859 localUri = vmUrls.where((Uri? vmUrl) => vmUrl != null).firstOrNull;
860 }
861 }
862 maxWaitForCI?.cancel();
863 await errorListener?.cancel();
864 return localUri;
865 }
866
867 /// Listen to device logs for crash on iOS 18.4+ due to JIT restriction. If
868 /// found, give guided error and throw tool exit. Returns null and does not
869 /// listen if device is less than iOS 18.4.
870 Future<StreamSubscription<String>?> _interceptErrorsFromLogs(
871 IOSApp? package, {
872 required DebuggingOptions debuggingOptions,
873 }) async {
874 // Currently only checking for kJITCrashFailureMessage, which only should
875 // be checked on iOS 18.4+.
876 if (sdkVersion == null || sdkVersion! < Version(18, 4, null)) {
877 return null;
878 }
879 final DeviceLogReader deviceLogReader = getLogReader(
880 app: package,
881 usingCISystem: debuggingOptions.usingCISystem,
882 );
883
884 final Stream<String> logStream = deviceLogReader.logLines;
885
886 final String deviceSdkVersion = await sdkNameAndVersion;
887
888 final StreamSubscription<String> errorListener = logStream.listen((String line) {
889 if (line.contains(kJITCrashFailureMessage)) {
890 throwToolExit(jITCrashFailureInstructions(deviceSdkVersion));
891 }
892 });
893
894 return errorListener;
895 }
896
897 ProtocolDiscovery _setupDebuggerAndVmServiceDiscovery({
898 required IOSApp package,
899 required Directory bundle,
900 required DebuggingOptions debuggingOptions,
901 required List<String> launchArguments,
902 required bool uninstallFirst,
903 bool skipInstall = false,
904 }) {
905 final DeviceLogReader deviceLogReader = getLogReader(
906 app: package,
907 usingCISystem: debuggingOptions.usingCISystem,
908 );
909
910 // If the device supports syslog reading, prefer launching the app without
911 // attaching the debugger to avoid the overhead of the unnecessary extra running process.
912 if (majorSdkVersion >= IOSDeviceLogReader.minimumUniversalLoggingSdkVersion) {
913 iosDeployDebugger = _iosDeploy.prepareDebuggerForLaunch(
914 deviceId: id,
915 bundlePath: bundle.path,
916 appDeltaDirectory: package.appDeltaDirectory,
917 launchArguments: launchArguments,
918 interfaceType: connectionInterface,
919 uninstallFirst: uninstallFirst,
920 skipInstall: skipInstall,
921 );
922 if (deviceLogReader is IOSDeviceLogReader) {
923 deviceLogReader.debuggerStream = iosDeployDebugger;
924 }
925 }
926 // Don't port forward if debugging with a wireless device.
927 return ProtocolDiscovery.vmService(
928 deviceLogReader,
929 portForwarder: isWirelesslyConnected ? null : portForwarder,
930 hostPort: debuggingOptions.hostVmServicePort,
931 devicePort: debuggingOptions.deviceVmServicePort,
932 ipv6: debuggingOptions.ipv6,
933 logger: _logger,
934 );
935 }
936
937 /// Uses either `devicectl` or Xcode automation to install, launch, and debug
938 /// apps on physical iOS devices.
939 ///
940 /// Starting with Xcode 15 and iOS 17, `ios-deploy` stopped working due to
941 /// the new CoreDevice connectivity stack. Previously, `ios-deploy` was used
942 /// to install the app, launch the app, and start `debugserver`.
943 ///
944 /// Xcode 15 introduced a new command line tool called `devicectl` that
945 /// includes much of the functionality supplied by `ios-deploy`. However,
946 /// `devicectl` lacked the ability to start a `debugserver` and therefore `ptrace`,
947 /// which are needed for debug mode due to using a JIT Dart VM.
948 ///
949 /// Xcode 16 introduced a command to lldb that allows you to start a debugserver, which
950 /// can be used in unison with `devicectl`.
951 ///
952 /// Therefore, when starting an app on a CoreDevice, use `devicectl` when
953 /// debugging is not enabled. If using Xcode 16, use `devicectl` and `lldb`.
954 /// Otherwise use Xcode automation.
955 Future<(bool, IOSDeploymentMethod)> _startAppOnCoreDevice({
956 required DebuggingOptions debuggingOptions,
957 required IOSApp package,
958 required List<String> launchArguments,
959 required String? mainPath,
960 required ShutdownHooks shutdownHooks,
961 @visibleForTesting Duration? discoveryTimeout,
962 }) async {
963 if (!debuggingOptions.debuggingEnabled) {
964 // Release mode
965
966 // Install app to device
967 final bool installSuccess = await _coreDeviceControl.installApp(
968 deviceId: id,
969 bundlePath: package.deviceBundlePath,
970 );
971 if (!installSuccess) {
972 return (installSuccess, IOSDeploymentMethod.coreDeviceWithoutDebugger);
973 }
974
975 // Launch app to device
976 final IOSCoreDeviceLaunchResult? launchResult = await _coreDeviceControl.launchApp(
977 deviceId: id,
978 bundleId: package.id,
979 launchArguments: launchArguments,
980 );
981 final bool launchSuccess = launchResult != null && launchResult.outcome == 'success';
982
983 return (launchSuccess, IOSDeploymentMethod.coreDeviceWithoutDebugger);
984 }
985
986 IOSDeploymentMethod? deploymentMethod;
987
988 // Xcode 16 introduced a way to start and attach to a debugserver through LLDB.
989 // However, it doesn't work reliably until Xcode 26.
990 // Use LLDB if Xcode version is greater than 26 and the feature is enabled.
991 final Version? xcodeVersion = globals.xcode?.currentVersion;
992 final bool lldbFeatureEnabled = featureFlags.isLLDBDebuggingEnabled;
993 if (xcodeVersion != null && xcodeVersion.major >= 26 && lldbFeatureEnabled) {
994 final bool launchSuccess = await _coreDeviceLauncher.launchAppWithLLDBDebugger(
995 deviceId: id,
996 bundlePath: package.deviceBundlePath,
997 bundleId: package.id,
998 launchArguments: launchArguments,
999 );
1000
1001 // If it succeeds to launch with LLDB, return, otherwise continue on to
1002 // try launching with Xcode.
1003 if (launchSuccess) {
1004 return (launchSuccess, IOSDeploymentMethod.coreDeviceWithLLDB);
1005 } else {
1006 deploymentMethod = IOSDeploymentMethod.coreDeviceWithXcodeFallback;
1007 _analytics.send(
1008 Event.appleUsageEvent(
1009 workflow: 'ios-physical-deployment',
1010 parameter: IOSDeploymentMethod.coreDeviceWithLLDB.name,
1011 result: 'launch failed',
1012 ),
1013 );
1014 }
1015 }
1016
1017 deploymentMethod ??= IOSDeploymentMethod.coreDeviceWithXcode;
1018
1019 // If LLDB is not available or fails, fallback to using Xcode.
1020 _logger.printStatus(
1021 'You may be prompted to give access to control Xcode. Flutter uses Xcode '
1022 'to run your app. If access is not allowed, you can change this through '
1023 'your Settings > Privacy & Security > Automation.',
1024 );
1025 final launchTimeout = isWirelesslyConnected ? 45 : 30;
1026 final timer = Timer(discoveryTimeout ?? Duration(seconds: launchTimeout), () {
1027 _logger.printError(
1028 'Xcode is taking longer than expected to start debugging the app. '
1029 'If the issue persists, try closing Xcode and re-running your Flutter command.',
1030 );
1031 });
1032
1033 XcodeDebugProject debugProject;
1034 final FlutterProject flutterProject = FlutterProject.current();
1035
1036 if (package is PrebuiltIOSApp) {
1037 debugProject = await _xcodeDebug.createXcodeProjectWithCustomBundle(
1038 package.deviceBundlePath,
1039 templateRenderer: globals.templateRenderer,
1040 verboseLogging: _logger.isVerbose,
1041 );
1042 } else if (package is BuildableIOSApp) {
1043 // Before installing/launching/debugging with Xcode, update the build
1044 // settings to use a custom configuration build directory so Xcode
1045 // knows where to find the app bundle to launch.
1046 final Directory bundle = _fileSystem.directory(package.deviceBundlePath);
1047 await updateGeneratedXcodeProperties(
1048 project: flutterProject,
1049 buildInfo: debuggingOptions.buildInfo,
1050 targetOverride: mainPath,
1051 configurationBuildDir: bundle.parent.absolute.path,
1052 );
1053
1054 final IosProject project = package.project;
1055 final XcodeProjectInfo? projectInfo = await project.projectInfo();
1056 if (projectInfo == null) {
1057 globals.printError('Xcode project not found.');
1058 return (false, deploymentMethod);
1059 }
1060 if (project.xcodeWorkspace == null) {
1061 globals.printError('Unable to get Xcode workspace.');
1062 return (false, deploymentMethod);
1063 }
1064 final String? scheme = projectInfo.schemeFor(debuggingOptions.buildInfo);
1065 if (scheme == null) {
1066 projectInfo.reportFlavorNotFoundAndExit();
1067 }
1068
1069 _xcodeDebug.ensureXcodeDebuggerLaunchAction(project.xcodeProjectSchemeFile(scheme: scheme));
1070
1071 debugProject = XcodeDebugProject(
1072 scheme: scheme,
1073 xcodeProject: project.xcodeProject,
1074 xcodeWorkspace: project.xcodeWorkspace!,
1075 hostAppProjectName: project.hostAppProjectName,
1076 expectedConfigurationBuildDir: bundle.parent.absolute.path,
1077 verboseLogging: _logger.isVerbose,
1078 );
1079 } else {
1080 // This should not happen. Currently, only PrebuiltIOSApp and
1081 // BuildableIOSApp extend from IOSApp.
1082 _logger.printError('IOSApp type ${package.runtimeType} is not recognized.');
1083 return (false, deploymentMethod);
1084 }
1085
1086 // Core Devices (iOS 17 devices) are debugged through Xcode so don't
1087 // include these flags, which are used to check if the app was launched
1088 // via Flutter CLI and `ios-deploy`.
1089 final List<String> filteredLaunchArguments = launchArguments
1090 .where((String arg) => arg != '--enable-checked-mode' && arg != '--verify-entry-points')
1091 .toList();
1092
1093 final bool debugSuccess = await _xcodeDebug.debugApp(
1094 project: debugProject,
1095 deviceId: id,
1096 launchArguments: filteredLaunchArguments,
1097 );
1098 timer.cancel();
1099
1100 // Kill Xcode on shutdown when running from CI
1101 if (debuggingOptions.usingCISystem) {
1102 shutdownHooks.addShutdownHook(() => _xcodeDebug.exit(force: true));
1103 }
1104
1105 return (debugSuccess, deploymentMethod);
1106 }
1107
1108 @override
1109 Future<bool> stopApp(ApplicationPackage? app, {String? userIdentifier}) async {
1110 // If the debugger is not attached, killing the ios-deploy process won't stop the app.
1111 final IOSDeployDebugger? deployDebugger = iosDeployDebugger;
1112 if (deployDebugger != null && deployDebugger.debuggerAttached) {
1113 return deployDebugger.exit();
1114 }
1115 if (_xcodeDebug.debugStarted) {
1116 return _xcodeDebug.exit();
1117 }
1118 return _coreDeviceLauncher.stopApp(deviceId: id);
1119 }
1120
1121 @override
1122 Future<TargetPlatform> get targetPlatform async => TargetPlatform.ios;
1123
1124 @override
1125 Future<String> get sdkNameAndVersion async => 'iOS ${_sdkVersion ?? 'unknown version'}';
1126
1127 @override
1128 DeviceLogReader getLogReader({
1129 covariant IOSApp? app,
1130 bool includePastLogs = false,
1131 bool usingCISystem = false,
1132 }) {
1133 assert(!includePastLogs, 'Past log reading not supported on iOS devices.');
1134 return _logReaders.putIfAbsent(
1135 app,
1136 () => IOSDeviceLogReader.create(
1137 device: this,
1138 app: app,
1139 iMobileDevice: _iMobileDevice,
1140 usingCISystem: usingCISystem,
1141 ),
1142 );
1143 }
1144
1145 @visibleForTesting
1146 void setLogReader(IOSApp app, DeviceLogReader logReader) {
1147 _logReaders[app] = logReader;
1148 }
1149
1150 @override
1151 DevicePortForwarder get portForwarder => _portForwarder ??= IOSDevicePortForwarder(
1152 logger: _logger,
1153 iproxy: _iproxy,
1154 id: id,
1155 operatingSystemUtils: globals.os,
1156 );
1157
1158 @visibleForTesting
1159 set portForwarder(DevicePortForwarder forwarder) {
1160 _portForwarder = forwarder;
1161 }
1162
1163 @override
1164 void clearLogs() {}
1165
1166 @override
1167 VMServiceDiscoveryForAttach getVMServiceDiscoveryForAttach({
1168 String? appId,
1169 String? fuchsiaModule,
1170 int? filterDevicePort,
1171 int? expectedHostPort,
1172 required bool ipv6,
1173 required Logger logger,
1174 }) {
1175 final bool compatibleWithProtocolDiscovery =
1176 majorSdkVersion < IOSDeviceLogReader.minimumUniversalLoggingSdkVersion &&
1177 !isWirelesslyConnected;
1178 final mdnsVMServiceDiscoveryForAttach = MdnsVMServiceDiscoveryForAttach(
1179 device: this,
1180 appId: appId,
1181 deviceVmservicePort: filterDevicePort,
1182 hostVmservicePort: expectedHostPort,
1183 usesIpv6: ipv6,
1184 useDeviceIPAsHost: isWirelesslyConnected,
1185 );
1186
1187 if (compatibleWithProtocolDiscovery) {
1188 return DelegateVMServiceDiscoveryForAttach(<VMServiceDiscoveryForAttach>[
1189 mdnsVMServiceDiscoveryForAttach,
1190 super.getVMServiceDiscoveryForAttach(
1191 appId: appId,
1192 fuchsiaModule: fuchsiaModule,
1193 filterDevicePort: filterDevicePort,
1194 expectedHostPort: expectedHostPort,
1195 ipv6: ipv6,
1196 logger: logger,
1197 ),
1198 ]);
1199 } else {
1200 return mdnsVMServiceDiscoveryForAttach;
1201 }
1202 }
1203
1204 @override
1205 bool get supportsScreenshot {
1206 if (isCoreDevice) {
1207 // `idevicescreenshot` stopped working with iOS 17 / Xcode 15
1208 // (https://github.com/flutter/flutter/issues/128598).
1209 return false;
1210 }
1211 return _iMobileDevice.isInstalled;
1212 }
1213
1214 @override
1215 Future<void> takeScreenshot(File outputFile) async {
1216 await _iMobileDevice.takeScreenshot(outputFile, id, connectionInterface);
1217 }
1218
1219 @override
1220 bool isSupportedForProject(FlutterProject flutterProject) {
1221 return flutterProject.ios.existsSync();
1222 }
1223
1224 @override
1225 Future<void> dispose() async {
1226 for (final DeviceLogReader logReader in _logReaders.values) {
1227 logReader.dispose();
1228 }
1229 _logReaders.clear();
1230 await _portForwarder?.dispose();
1231 }
1232}
1233
1234/// Decodes a vis-encoded syslog string to a UTF-8 representation.
1235///
1236/// Apple's syslog logs are encoded in 7-bit form. Input bytes are encoded as follows:
1237/// 1. 0x00 to 0x19: non-printing range. Some ignored, some encoded as <...>.
1238/// 2. 0x20 to 0x7f: as-is, with the exception of 0x5c (backslash).
1239/// 3. 0x5c (backslash): octal representation \134.
1240/// 4. 0x80 to 0x9f: \M^x (using control-character notation for range 0x00 to 0x40).
1241/// 5. 0xa0: octal representation \240.
1242/// 6. 0xa1 to 0xf7: \M-x (where x is the input byte stripped of its high-order bit).
1243/// 7. 0xf8 to 0xff: unused in 4-byte UTF-8.
1244///
1245/// See: [vis(3) manpage](https://www.freebsd.org/cgi/man.cgi?query=vis&sektion=3)
1246String decodeSyslog(String line) {
1247 // UTF-8 values for \, M, -, ^.
1248 const kBackslash = 0x5c;
1249 const kM = 0x4d;
1250 const kDash = 0x2d;
1251 const kCaret = 0x5e;
1252
1253 // Mask for the UTF-8 digit range.
1254 const kNum = 0x30;
1255
1256 // Returns true when `byte` is within the UTF-8 7-bit digit range (0x30 to 0x39).
1257 bool isDigit(int byte) => (byte & 0xf0) == kNum;
1258
1259 // Converts a three-digit ASCII (UTF-8) representation of an octal number `xyz` to an integer.
1260 int decodeOctal(int x, int y, int z) => (x & 0x3) << 6 | (y & 0x7) << 3 | z & 0x7;
1261
1262 try {
1263 final List<int> bytes = utf8.encode(line);
1264 final out = <int>[];
1265 for (var i = 0; i < bytes.length;) {
1266 if (bytes[i] != kBackslash || i > bytes.length - 4) {
1267 // Unmapped byte: copy as-is.
1268 out.add(bytes[i++]);
1269 } else {
1270 // Mapped byte: decode next 4 bytes.
1271 if (bytes[i + 1] == kM && bytes[i + 2] == kCaret) {
1272 // \M^x form: bytes in range 0x80 to 0x9f.
1273 out.add((bytes[i + 3] & 0x7f) + 0x40);
1274 } else if (bytes[i + 1] == kM && bytes[i + 2] == kDash) {
1275 // \M-x form: bytes in range 0xa0 to 0xf7.
1276 out.add(bytes[i + 3] | 0x80);
1277 } else if (bytes.getRange(i + 1, i + 3).every(isDigit)) {
1278 // \ddd form: octal representation (only used for \134 and \240).
1279 out.add(decodeOctal(bytes[i + 1], bytes[i + 2], bytes[i + 3]));
1280 } else {
1281 // Unknown form: copy as-is.
1282 out.addAll(bytes.getRange(0, 4));
1283 }
1284 i += 4;
1285 }
1286 }
1287 return utf8.decode(out);
1288 } on Exception {
1289 // Unable to decode line: return as-is.
1290 return line;
1291 }
1292}
1293
1294class IOSDeviceLogReader extends DeviceLogReader {
1295 IOSDeviceLogReader._(
1296 this._iMobileDevice,
1297 this._majorSdkVersion,
1298 this._deviceId,
1299 this.name,
1300 this._isWirelesslyConnected,
1301 this._isCoreDevice,
1302 String appName,
1303 bool usingCISystem,
1304 ) : // Match for lines for the runner in syslog.
1305 //
1306 // iOS 9 format: Runner[297] :
1307 // iOS 10 format: Runner(Flutter)[297] :
1308 _runnerLineRegex = RegExp(appName + r'(\(Flutter\))?\[[\d]+\] <[A-Za-z]+>: '),
1309 _usingCISystem = usingCISystem;
1310
1311 /// Create a new [IOSDeviceLogReader].
1312 factory IOSDeviceLogReader.create({
1313 required IOSDevice device,
1314 IOSApp? app,
1315 required IMobileDevice iMobileDevice,
1316 bool usingCISystem = false,
1317 }) {
1318 final String appName = app?.name?.replaceAll('.app', '') ?? '';
1319 return IOSDeviceLogReader._(
1320 iMobileDevice,
1321 device.majorSdkVersion,
1322 device.id,
1323 device.displayName,
1324 device.isWirelesslyConnected,
1325 device.isCoreDevice,
1326 appName,
1327 usingCISystem,
1328 );
1329 }
1330
1331 /// Create an [IOSDeviceLogReader] for testing.
1332 factory IOSDeviceLogReader.test({
1333 required IMobileDevice iMobileDevice,
1334 bool useSyslog = true,
1335 bool usingCISystem = false,
1336 int? majorSdkVersion,
1337 bool isWirelesslyConnected = false,
1338 bool isCoreDevice = false,
1339 }) {
1340 final int sdkVersion = majorSdkVersion ?? (useSyslog ? 12 : 13);
1341 return IOSDeviceLogReader._(
1342 iMobileDevice,
1343 sdkVersion,
1344 '1234',
1345 'test',
1346 isWirelesslyConnected,
1347 isCoreDevice,
1348 'Runner',
1349 usingCISystem,
1350 );
1351 }
1352
1353 @override
1354 final String name;
1355 final int _majorSdkVersion;
1356 final String _deviceId;
1357 final bool _isWirelesslyConnected;
1358 final bool _isCoreDevice;
1359 final IMobileDevice _iMobileDevice;
1360 final bool _usingCISystem;
1361
1362 // Matches a syslog line from the runner.
1363 RegExp _runnerLineRegex;
1364
1365 // Similar to above, but allows ~arbitrary components instead of "Runner"
1366 // and "Flutter". The regex tries to strike a balance between not producing
1367 // false positives and not producing false negatives.
1368 final _anyLineRegex = RegExp(r'\w+(\([^)]*\))?\[\d+\] <[A-Za-z]+>: ');
1369
1370 // Logging from native code/Flutter engine is prefixed by timestamp and process metadata:
1371 // 2020-09-15 19:15:10.931434-0700 Runner[541:226276] Did finish launching.
1372 // 2020-09-15 19:15:10.931434-0700 Runner[541:226276] [Category] Did finish launching.
1373 //
1374 // Logging from the dart code has no prefixing metadata.
1375 final _debuggerLoggingRegex = RegExp(r'^\S* \S* \S*\[[0-9:]*] (.*)');
1376
1377 @visibleForTesting
1378 late final linesController = StreamController<String>.broadcast(
1379 onListen: _listenToSysLog,
1380 onCancel: dispose,
1381 );
1382
1383 // Sometimes (race condition?) we try to send a log after the controller has
1384 // been closed. See https://github.com/flutter/flutter/issues/99021 for more
1385 // context.
1386 @visibleForTesting
1387 void addToLinesController(String message, IOSDeviceLogSource source) {
1388 if (!linesController.isClosed) {
1389 if (_excludeLog(message, source)) {
1390 return;
1391 }
1392 linesController.add(message);
1393 }
1394 }
1395
1396 /// Used to track messages prefixed with "flutter:" from the fallback log source.
1397 final _fallbackStreamFlutterMessages = <String>[];
1398
1399 /// Used to track if a message prefixed with "flutter:" has been received from the primary log.
1400 var primarySourceFlutterLogReceived = false;
1401
1402 /// There are three potential logging sources: `idevicesyslog`, `ios-deploy`,
1403 /// and Unified Logging (Dart VM). When using more than one of these logging
1404 /// sources at a time, prefer to use the primary source. However, if the
1405 /// primary source is not working, use the fallback.
1406 bool _excludeLog(String message, IOSDeviceLogSource source) {
1407 // If no fallback, don't exclude any logs.
1408 if (logSources.fallbackSource == null) {
1409 return false;
1410 }
1411
1412 // If log is from primary source, don't exclude it unless the fallback was
1413 // quicker and added the message first.
1414 if (source == logSources.primarySource) {
1415 if (!primarySourceFlutterLogReceived && message.startsWith('flutter:')) {
1416 primarySourceFlutterLogReceived = true;
1417 }
1418
1419 // If the message was already added by the fallback, exclude it to
1420 // prevent duplicates.
1421 final bool foundAndRemoved = _fallbackStreamFlutterMessages.remove(message);
1422 if (foundAndRemoved) {
1423 return true;
1424 }
1425 return false;
1426 }
1427
1428 // If a flutter log was received from the primary source, that means it's
1429 // working so don't use any messages from the fallback.
1430 if (primarySourceFlutterLogReceived) {
1431 return true;
1432 }
1433
1434 // When using logs from fallbacks, skip any logs not prefixed with "flutter:".
1435 // This is done because different sources often have different prefixes for
1436 // non-flutter messages, which makes duplicate matching difficult. Also,
1437 // non-flutter messages are not critical for CI tests.
1438 if (!message.startsWith('flutter:')) {
1439 return true;
1440 }
1441
1442 _fallbackStreamFlutterMessages.add(message);
1443 return false;
1444 }
1445
1446 final _loggingSubscriptions = <StreamSubscription<void>>[];
1447
1448 @override
1449 Stream<String> get logLines => linesController.stream;
1450
1451 FlutterVmService? _connectedVmService;
1452
1453 @override
1454 Future<void> provideVmService(FlutterVmService connectedVmService) async {
1455 await _listenToUnifiedLoggingEvents(connectedVmService);
1456 _connectedVmService = connectedVmService;
1457 }
1458
1459 static const minimumUniversalLoggingSdkVersion = 13;
1460
1461 /// Determine the primary and fallback source for device logs.
1462 ///
1463 /// There are three potential logging sources: `idevicesyslog`, `ios-deploy`,
1464 /// and Unified Logging (Dart VM).
1465 @visibleForTesting
1466 _IOSDeviceLogSources get logSources {
1467 // `ios-deploy` stopped working with iOS 17 / Xcode 15, so use `idevicesyslog` instead.
1468 // However, `idevicesyslog` is sometimes unreliable so use Dart VM as a fallback.
1469 // Also, `idevicesyslog` does not work with iOS 17 wireless devices, so use the
1470 // Dart VM for wireless devices.
1471 if (_isCoreDevice) {
1472 if (_isWirelesslyConnected) {
1473 return _IOSDeviceLogSources(primarySource: IOSDeviceLogSource.unifiedLogging);
1474 }
1475 return _IOSDeviceLogSources(
1476 primarySource: IOSDeviceLogSource.idevicesyslog,
1477 fallbackSource: IOSDeviceLogSource.unifiedLogging,
1478 );
1479 }
1480
1481 // Use `idevicesyslog` for iOS 12 or less.
1482 // Syslog stopped working on iOS 13 (https://github.com/flutter/flutter/issues/41133).
1483 // However, from at least iOS 16, it has began working again. It's unclear
1484 // why it started working again.
1485 if (_majorSdkVersion < minimumUniversalLoggingSdkVersion) {
1486 return _IOSDeviceLogSources(primarySource: IOSDeviceLogSource.idevicesyslog);
1487 }
1488
1489 // Use `idevicesyslog` as a fallback to `ios-deploy` when debugging from
1490 // CI system since sometimes `ios-deploy` does not return the device logs:
1491 // https://github.com/flutter/flutter/issues/121231
1492 if (_usingCISystem && _majorSdkVersion >= 16) {
1493 return _IOSDeviceLogSources(
1494 primarySource: IOSDeviceLogSource.iosDeploy,
1495 fallbackSource: IOSDeviceLogSource.idevicesyslog,
1496 );
1497 }
1498
1499 // Use `ios-deploy` to stream logs from the device when the device is not a
1500 // CoreDevice and has iOS 13 or greater.
1501 // When using `ios-deploy` and the Dart VM, prefer the more complete logs
1502 // from the attached debugger, if available.
1503 if (_connectedVmService != null &&
1504 (_iosDeployDebugger == null || !_iosDeployDebugger!.debuggerAttached)) {
1505 return _IOSDeviceLogSources(
1506 primarySource: IOSDeviceLogSource.unifiedLogging,
1507 fallbackSource: IOSDeviceLogSource.iosDeploy,
1508 );
1509 }
1510 return _IOSDeviceLogSources(
1511 primarySource: IOSDeviceLogSource.iosDeploy,
1512 fallbackSource: IOSDeviceLogSource.unifiedLogging,
1513 );
1514 }
1515
1516 /// Whether `idevicesyslog` is used as either the primary or fallback source for device logs.
1517 @visibleForTesting
1518 bool get useSyslogLogging {
1519 return logSources.primarySource == IOSDeviceLogSource.idevicesyslog ||
1520 logSources.fallbackSource == IOSDeviceLogSource.idevicesyslog;
1521 }
1522
1523 /// Whether the Dart VM is used as either the primary or fallback source for device logs.
1524 ///
1525 /// Unified Logging only works after the Dart VM has been connected to.
1526 @visibleForTesting
1527 bool get useUnifiedLogging {
1528 return logSources.primarySource == IOSDeviceLogSource.unifiedLogging ||
1529 logSources.fallbackSource == IOSDeviceLogSource.unifiedLogging;
1530 }
1531
1532 /// Whether `ios-deploy` is used as either the primary or fallback source for device logs.
1533 @visibleForTesting
1534 bool get useIOSDeployLogging {
1535 return logSources.primarySource == IOSDeviceLogSource.iosDeploy ||
1536 logSources.fallbackSource == IOSDeviceLogSource.iosDeploy;
1537 }
1538
1539 /// Listen to Dart VM for logs on iOS 13 or greater.
1540 Future<void> _listenToUnifiedLoggingEvents(FlutterVmService connectedVmService) async {
1541 if (!useUnifiedLogging) {
1542 return;
1543 }
1544 try {
1545 // The VM service will not publish logging events unless the debug stream is being listened to.
1546 // Listen to this stream as a side effect.
1547 unawaited(connectedVmService.service.streamListen('Debug'));
1548
1549 await Future.wait(<Future<void>>[
1550 connectedVmService.service.streamListen(vm_service.EventStreams.kStdout),
1551 connectedVmService.service.streamListen(vm_service.EventStreams.kStderr),
1552 ]);
1553 } on vm_service.RPCError {
1554 // Do nothing, since the tool is already subscribed.
1555 }
1556
1557 void logMessage(vm_service.Event event) {
1558 final String message = processVmServiceMessage(event);
1559 if (message.isNotEmpty) {
1560 addToLinesController(message, IOSDeviceLogSource.unifiedLogging);
1561 }
1562 }
1563
1564 _loggingSubscriptions.addAll(<StreamSubscription<void>>[
1565 connectedVmService.service.onStdoutEvent.listen(logMessage),
1566 connectedVmService.service.onStderrEvent.listen(logMessage),
1567 ]);
1568 }
1569
1570 /// Log reader will listen to [IOSDeployDebugger.logLines] and
1571 /// will detach debugger on dispose.
1572 IOSDeployDebugger? get debuggerStream => _iosDeployDebugger;
1573
1574 /// Send messages from ios-deploy debugger stream to device log reader stream.
1575 set debuggerStream(IOSDeployDebugger? debugger) {
1576 // Logging is gathered from syslog on iOS earlier than 13.
1577 if (!useIOSDeployLogging) {
1578 return;
1579 }
1580 _iosDeployDebugger = debugger;
1581 if (debugger == null) {
1582 return;
1583 }
1584 // Add the debugger logs to the controller created on initialization.
1585 _loggingSubscriptions.add(
1586 debugger.logLines.listen(
1587 (String line) =>
1588 addToLinesController(_debuggerLineHandler(line), IOSDeviceLogSource.iosDeploy),
1589 onError: linesController.addError,
1590 onDone: linesController.close,
1591 cancelOnError: true,
1592 ),
1593 );
1594 }
1595
1596 IOSDeployDebugger? _iosDeployDebugger;
1597
1598 // Strip off the logging metadata (leave the category), or just echo the line.
1599 String _debuggerLineHandler(String line) =>
1600 _debuggerLoggingRegex.firstMatch(line)?.group(1) ?? line;
1601
1602 /// Start and listen to `idevicesyslog` to get device logs for iOS versions
1603 /// prior to 13 or if [useSyslogLogging] and [useIOSDeployLogging] are `true`.
1604 void _listenToSysLog() {
1605 if (!useSyslogLogging) {
1606 return;
1607 }
1608 _iMobileDevice.startLogger(_deviceId, _isWirelesslyConnected).then<void>((Process process) {
1609 process.stdout
1610 .transform<String>(utf8.decoder)
1611 .transform<String>(const LineSplitter())
1612 .listen(_newSyslogLineHandler());
1613 process.stderr
1614 .transform<String>(utf8.decoder)
1615 .transform<String>(const LineSplitter())
1616 .listen(_newSyslogLineHandler());
1617 process.exitCode.whenComplete(() {
1618 if (!linesController.hasListener) {
1619 return;
1620 }
1621 // When using both log readers, do not close the stream on exit.
1622 // This is to allow ios-deploy to be the source of authority to close
1623 // the stream.
1624 if (useSyslogLogging && useIOSDeployLogging && debuggerStream != null) {
1625 return;
1626 }
1627 linesController.close();
1628 });
1629 assert(idevicesyslogProcess == null);
1630 idevicesyslogProcess = process;
1631 });
1632 }
1633
1634 @visibleForTesting
1635 Process? idevicesyslogProcess;
1636
1637 // Returns a stateful line handler to properly capture multiline output.
1638 //
1639 // For multiline log messages, any line after the first is logged without
1640 // any specific prefix. To properly capture those, we enter "printing" mode
1641 // after matching a log line from the runner. When in printing mode, we print
1642 // all lines until we find the start of another log message (from any app).
1643 void Function(String line) _newSyslogLineHandler() {
1644 var printing = false;
1645
1646 return (String line) {
1647 if (printing) {
1648 if (!_anyLineRegex.hasMatch(line)) {
1649 addToLinesController(decodeSyslog(line), IOSDeviceLogSource.idevicesyslog);
1650 return;
1651 }
1652
1653 printing = false;
1654 }
1655
1656 final Match? match = _runnerLineRegex.firstMatch(line);
1657
1658 if (match != null) {
1659 final String logLine = line.substring(match.end);
1660 // Only display the log line after the initial device and executable information.
1661 addToLinesController(decodeSyslog(logLine), IOSDeviceLogSource.idevicesyslog);
1662 printing = true;
1663 }
1664 };
1665 }
1666
1667 @override
1668 void dispose() {
1669 for (final StreamSubscription<void> loggingSubscription in _loggingSubscriptions) {
1670 loggingSubscription.cancel();
1671 }
1672 idevicesyslogProcess?.kill();
1673 _iosDeployDebugger?.detach();
1674 }
1675}
1676
1677enum IOSDeviceLogSource {
1678 /// Gets logs from ios-deploy debugger.
1679 iosDeploy,
1680
1681 /// Gets logs from idevicesyslog.
1682 idevicesyslog,
1683
1684 /// Gets logs from the Dart VM Service.
1685 unifiedLogging,
1686}
1687
1688class _IOSDeviceLogSources {
1689 _IOSDeviceLogSources({required this.primarySource, this.fallbackSource});
1690
1691 final IOSDeviceLogSource primarySource;
1692 final IOSDeviceLogSource? fallbackSource;
1693}
1694
1695/// A [DevicePortForwarder] specialized for iOS usage with iproxy.
1696class IOSDevicePortForwarder extends DevicePortForwarder {
1697 /// Create a new [IOSDevicePortForwarder].
1698 IOSDevicePortForwarder({
1699 required Logger logger,
1700 required String id,
1701 required IProxy iproxy,
1702 required OperatingSystemUtils operatingSystemUtils,
1703 }) : _logger = logger,
1704 _id = id,
1705 _iproxy = iproxy,
1706 _operatingSystemUtils = operatingSystemUtils;
1707
1708 /// Create a [IOSDevicePortForwarder] for testing.
1709 ///
1710 /// This specifies the path to iproxy as 'iproxy` and the dyLdLibEntry as
1711 /// 'DYLD_LIBRARY_PATH: /path/to/libs'.
1712 ///
1713 /// The device id may be provided, but otherwise defaults to '1234'.
1714 factory IOSDevicePortForwarder.test({
1715 required ProcessManager processManager,
1716 required Logger logger,
1717 String? id,
1718 required OperatingSystemUtils operatingSystemUtils,
1719 }) {
1720 return IOSDevicePortForwarder(
1721 logger: logger,
1722 iproxy: IProxy.test(logger: logger, processManager: processManager),
1723 id: id ?? '1234',
1724 operatingSystemUtils: operatingSystemUtils,
1725 );
1726 }
1727
1728 final Logger _logger;
1729 final String _id;
1730 final IProxy _iproxy;
1731 final OperatingSystemUtils _operatingSystemUtils;
1732
1733 @override
1734 var forwardedPorts = <ForwardedPort>[];
1735
1736 @visibleForTesting
1737 void addForwardedPorts(List<ForwardedPort> ports) {
1738 ports.forEach(forwardedPorts.add);
1739 }
1740
1741 static const _kiProxyPortForwardTimeout = Duration(seconds: 1);
1742
1743 @override
1744 Future<int> forward(int devicePort, {int? hostPort}) async {
1745 final bool autoselect = hostPort == null || hostPort == 0;
1746 if (autoselect) {
1747 final int freePort = await _operatingSystemUtils.findFreePort();
1748 // Dynamic port range 49152 - 65535.
1749 hostPort = freePort == 0 ? 49152 : freePort;
1750 }
1751
1752 Process? process;
1753
1754 var connected = false;
1755 while (!connected) {
1756 _logger.printTrace('Attempting to forward device port $devicePort to host port $hostPort');
1757 process = await _iproxy.forward(devicePort, hostPort!, _id);
1758 // TODO(ianh): This is a flaky race condition, https://github.com/libimobiledevice/libimobiledevice/issues/674
1759 connected = !await process.stdout.isEmpty.timeout(
1760 _kiProxyPortForwardTimeout,
1761 onTimeout: () => false,
1762 );
1763 if (!connected) {
1764 process.kill();
1765 if (autoselect) {
1766 hostPort += 1;
1767 if (hostPort > 65535) {
1768 throw Exception('Could not find open port on host.');
1769 }
1770 } else {
1771 throw Exception('Port $hostPort is not available.');
1772 }
1773 }
1774 }
1775 assert(connected);
1776 assert(process != null);
1777
1778 final forwardedPort = ForwardedPort.withContext(hostPort!, devicePort, process);
1779 _logger.printTrace('Forwarded port $forwardedPort');
1780 forwardedPorts.add(forwardedPort);
1781 return hostPort;
1782 }
1783
1784 @override
1785 Future<void> unforward(ForwardedPort forwardedPort) async {
1786 if (!forwardedPorts.remove(forwardedPort)) {
1787 // Not in list. Nothing to remove.
1788 return;
1789 }
1790
1791 _logger.printTrace('Un-forwarding port $forwardedPort');
1792 forwardedPort.dispose();
1793 }
1794
1795 @override
1796 Future<void> dispose() async {
1797 for (final ForwardedPort forwardedPort in forwardedPorts) {
1798 forwardedPort.dispose();
1799 }
1800 }
1801}
1802