From 761810a0bf3afdf593f56661f42cc943dbeb6615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20S=20Guerrero?= Date: Fri, 7 Jul 2023 11:06:18 -0600 Subject: [PATCH 01/58] =?UTF-8?q?Revert=20"fix=20a=20bug=20when=20android?= =?UTF-8?q?=20uses=20CupertinoPageTransitionsBuilder..=E2=80=A6=20(#130155?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit โ€ฆ." (#130144) Reverts flutter/flutter#114303 The breaking API change in flutter/flutter#114303 broke internal tests/apps (Google internal link b/290154304) as well as external dependents: https://github.com/flutter/flutter/issues/130062. Co-authored-by: Hans Muller --- packages/flutter/lib/src/material/page.dart | 18 +-- .../src/material/page_transitions_theme.dart | 9 +- .../material/page_transitions_theme_test.dart | 136 ------------------ 3 files changed, 9 insertions(+), 154 deletions(-) diff --git a/packages/flutter/lib/src/material/page.dart b/packages/flutter/lib/src/material/page.dart index 4ab01b2d591ce..73d677ff4cd78 100644 --- a/packages/flutter/lib/src/material/page.dart +++ b/packages/flutter/lib/src/material/page.dart @@ -80,8 +80,6 @@ class MaterialPageRoute extends PageRoute with MaterialRouteTransitionMixi /// * [CupertinoPageTransitionsBuilder], which is the default page transition /// for iOS and macOS. mixin MaterialRouteTransitionMixin on PageRoute { - TargetPlatform? _effectiveTargetPlatform; - /// Builds the primary contents of the route. @protected Widget buildContent(BuildContext context); @@ -118,20 +116,8 @@ mixin MaterialRouteTransitionMixin on PageRoute { @override Widget buildTransitions(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { - return ValueListenableBuilder( - valueListenable: navigator!.userGestureInProgressNotifier, - builder: (BuildContext context, bool useGestureInProgress, Widget? _) { - final ThemeData themeData = Theme.of(context); - - if (useGestureInProgress) { - // The platform should be kept unchanged during an user gesture. - _effectiveTargetPlatform ??= themeData.platform; - } else { - _effectiveTargetPlatform = themeData.platform; - } - return themeData.pageTransitionsTheme.buildTransitions(this, context, animation, secondaryAnimation, child, _effectiveTargetPlatform!); - }, - ); + final PageTransitionsTheme theme = Theme.of(context).pageTransitionsTheme; + return theme.buildTransitions(this, context, animation, secondaryAnimation, child); } } diff --git a/packages/flutter/lib/src/material/page_transitions_theme.dart b/packages/flutter/lib/src/material/page_transitions_theme.dart index 6cf76570dfbd7..7c960a8362f79 100644 --- a/packages/flutter/lib/src/material/page_transitions_theme.dart +++ b/packages/flutter/lib/src/material/page_transitions_theme.dart @@ -741,7 +741,7 @@ class PageTransitionsTheme with Diagnosticable { Map get builders => _builders; final Map _builders; - /// Delegates to the builder for the current [platform]. + /// Delegates to the builder for the current [ThemeData.platform]. /// If a builder for the current platform is not found, then the /// [ZoomPageTransitionsBuilder] is used. /// @@ -752,8 +752,13 @@ class PageTransitionsTheme with Diagnosticable { Animation animation, Animation secondaryAnimation, Widget child, - TargetPlatform platform, ) { + TargetPlatform platform = Theme.of(context).platform; + + if (CupertinoRouteTransitionMixin.isPopGestureInProgress(route)) { + platform = TargetPlatform.iOS; + } + final PageTransitionsBuilder matchingBuilder = builders[platform] ?? const ZoomPageTransitionsBuilder(); return matchingBuilder.buildTransitions(route, context, animation, secondaryAnimation, child); diff --git a/packages/flutter/test/material/page_transitions_theme_test.dart b/packages/flutter/test/material/page_transitions_theme_test.dart index e1f933da48202..323d4ed0c03d2 100644 --- a/packages/flutter/test/material/page_transitions_theme_test.dart +++ b/packages/flutter/test/material/page_transitions_theme_test.dart @@ -350,140 +350,4 @@ void main() { await tester.pumpAndSettle(); expect(builtCount, 1); }, variant: TargetPlatformVariant.only(TargetPlatform.android)); - - testWidgets('android can use CupertinoPageTransitionsBuilder', (WidgetTester tester) async { - int builtCount = 0; - - final Map routes = { - '/': (BuildContext context) => Material( - child: TextButton( - child: const Text('push'), - onPressed: () { Navigator.of(context).pushNamed('/b'); }, - ), - ), - '/b': (BuildContext context) => StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - builtCount++; - return TextButton( - child: const Text('pop'), - onPressed: () { Navigator.pop(context); }, - ); - }, - ), - }; - - await tester.pumpWidget( - MaterialApp( - theme: ThemeData( - pageTransitionsTheme: const PageTransitionsTheme( - builders: { - TargetPlatform.android: CupertinoPageTransitionsBuilder(), - // iOS uses different PageTransitionsBuilder - TargetPlatform.iOS: FadeUpwardsPageTransitionsBuilder(), - }, - ), - ), - routes: routes, - ), - ); - - // No matter push or pop was called, the child widget should built only once. - await tester.tap(find.text('push')); - await tester.pumpAndSettle(); - expect(builtCount, 1); - - final Size size = tester.getSize(find.byType(MaterialApp)); - await tester.flingFrom(Offset(0, size.height / 2), Offset(size.width * 2 / 3, 0), 500); - - await tester.pumpAndSettle(); - expect(find.text('push'), findsOneWidget); - expect(builtCount, 1); - }, variant: TargetPlatformVariant.only(TargetPlatform.android)); - - testWidgets('back gesture while TargetPlatform changes', (WidgetTester tester) async { - final Map routes = { - '/': (BuildContext context) => Material( - child: TextButton( - child: const Text('PUSH'), - onPressed: () { Navigator.of(context).pushNamed('/b'); }, - ), - ), - '/b': (BuildContext context) => const Text('HELLO'), - }; - const PageTransitionsTheme pageTransitionsTheme = PageTransitionsTheme( - builders: { - TargetPlatform.android: CupertinoPageTransitionsBuilder(), - // iOS uses different PageTransitionsBuilder - TargetPlatform.iOS: FadeUpwardsPageTransitionsBuilder(), - }, - ); - await tester.pumpWidget( - MaterialApp( - theme: ThemeData( - platform: TargetPlatform.android, - pageTransitionsTheme: pageTransitionsTheme, - ), - routes: routes, - ), - ); - await tester.tap(find.text('PUSH')); - expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 2); - expect(find.text('PUSH'), findsNothing); - expect(find.text('HELLO'), findsOneWidget); - - final Offset helloPosition1 = tester.getCenter(find.text('HELLO')); - final TestGesture gesture = await tester.startGesture(const Offset(2.5, 300.0)); - await tester.pump(const Duration(milliseconds: 20)); - await gesture.moveBy(const Offset(100.0, 0.0)); - expect(find.text('PUSH'), findsNothing); - expect(find.text('HELLO'), findsOneWidget); - await tester.pump(const Duration(milliseconds: 20)); - expect(find.text('PUSH'), findsOneWidget); - expect(find.text('HELLO'), findsOneWidget); - final Offset helloPosition2 = tester.getCenter(find.text('HELLO')); - expect(helloPosition1.dx, lessThan(helloPosition2.dx)); - expect(helloPosition1.dy, helloPosition2.dy); - expect(Theme.of(tester.element(find.text('HELLO'))).platform, TargetPlatform.android); - - await tester.pumpWidget( - MaterialApp( - theme: ThemeData( - platform: TargetPlatform.iOS, - pageTransitionsTheme: pageTransitionsTheme, - ), - routes: routes, - ), - ); - // Now, let the theme animation run through. - // This takes three frames (including the first one above): - // 1. Start the Theme animation. It's at t=0 so everything else is identical. - // 2. Start any animations that are informed by the Theme, for example, the - // DefaultTextStyle, on the first frame that the theme is not at t=0. In - // this case, it's at t=1.0 of the theme animation, so this is also the - // frame in which the theme animation ends. - // 3. End all the other animations. - expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 2); - expect(Theme.of(tester.element(find.text('HELLO'))).platform, TargetPlatform.iOS); - final Offset helloPosition3 = tester.getCenter(find.text('HELLO')); - expect(helloPosition3, helloPosition2); - expect(find.text('PUSH'), findsOneWidget); - expect(find.text('HELLO'), findsOneWidget); - await gesture.moveBy(const Offset(100.0, 0.0)); - await tester.pump(const Duration(milliseconds: 20)); - expect(find.text('PUSH'), findsOneWidget); - expect(find.text('HELLO'), findsOneWidget); - final Offset helloPosition4 = tester.getCenter(find.text('HELLO')); - expect(helloPosition3.dx, lessThan(helloPosition4.dx)); - expect(helloPosition3.dy, helloPosition4.dy); - await gesture.moveBy(const Offset(500.0, 0.0)); - await gesture.up(); - expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 3); - expect(find.text('PUSH'), findsOneWidget); - expect(find.text('HELLO'), findsNothing); - - await tester.tap(find.text('PUSH')); - expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 2); - expect(find.text('PUSH'), findsNothing); - expect(find.text('HELLO'), findsOneWidget); - }); } From 257a29931c6b25d1a9690b7004f44921487bedb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20S=20Guerrero?= Date: Fri, 7 Jul 2023 14:25:30 -0600 Subject: [PATCH 02/58] Revert "[a11y] CupertinoSwitch On/Off labels" (#130166) (#130172) Reverts flutter/flutter#127776 Currently breaking google testing --- .../flutter/lib/src/cupertino/switch.dart | 106 ---------- .../flutter/lib/src/widgets/media_query.dart | 41 ---- .../flutter/test/cupertino/switch_test.dart | 181 ------------------ .../test/widgets/media_query_test.dart | 55 ------ 4 files changed, 383 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/switch.dart b/packages/flutter/lib/src/cupertino/switch.dart index 75518bcdec84f..2057e115a214f 100644 --- a/packages/flutter/lib/src/cupertino/switch.dart +++ b/packages/flutter/lib/src/cupertino/switch.dart @@ -75,8 +75,6 @@ class CupertinoSwitch extends StatefulWidget { this.thumbColor, this.applyTheme, this.focusColor, - this.onLabelColor, - this.offLabelColor, this.focusNode, this.onFocusChange, this.autofocus = false, @@ -135,17 +133,6 @@ class CupertinoSwitch extends StatefulWidget { /// Defaults to a slightly transparent [activeColor]. final Color? focusColor; - /// The color to use for the accessibility label when the switch is on. - /// - /// Defaults to [CupertinoColors.white] when null. - final Color? onLabelColor; - - /// The color to use for the accessibility label when the switch is off. - /// - /// Defaults to [Color.fromARGB(255, 179, 179, 179)] - /// (or [Color.fromARGB(255, 255, 255, 255)] in high contrast) when null. - final Color? offLabelColor; - /// {@macro flutter.widgets.Focus.focusNode} final FocusNode? focusNode; @@ -370,19 +357,6 @@ class _CupertinoSwitchState extends State with TickerProviderSt ?? CupertinoColors.systemGreen, context, ); - final (Color onLabelColor, Color offLabelColor)? onOffLabelColors = - MediaQuery.onOffSwitchLabelsOf(context) - ? ( - CupertinoDynamicColor.resolve( - widget.onLabelColor ?? CupertinoColors.white, - context, - ), - CupertinoDynamicColor.resolve( - widget.offLabelColor ?? _kOffLabelColor, - context, - ), - ) - : null; if (needsPositionAnimation) { _resumePositionAnimation(); } @@ -415,7 +389,6 @@ class _CupertinoSwitchState extends State with TickerProviderSt textDirection: Directionality.of(context), isFocused: isFocused, state: this, - onOffLabelColors: onOffLabelColors, ), ), ), @@ -444,7 +417,6 @@ class _CupertinoSwitchRenderObjectWidget extends LeafRenderObjectWidget { required this.textDirection, required this.isFocused, required this.state, - required this.onOffLabelColors, }); final bool value; @@ -456,7 +428,6 @@ class _CupertinoSwitchRenderObjectWidget extends LeafRenderObjectWidget { final _CupertinoSwitchState state; final TextDirection textDirection; final bool isFocused; - final (Color onLabelColor, Color offLabelColor)? onOffLabelColors; @override _RenderCupertinoSwitch createRenderObject(BuildContext context) { @@ -470,7 +441,6 @@ class _CupertinoSwitchRenderObjectWidget extends LeafRenderObjectWidget { textDirection: textDirection, isFocused: isFocused, state: state, - onOffLabelColors: onOffLabelColors, ); } @@ -497,24 +467,6 @@ const double _kTrackInnerEnd = _kTrackWidth - _kTrackInnerStart; const double _kTrackInnerLength = _kTrackInnerEnd - _kTrackInnerStart; const double _kSwitchWidth = 59.0; const double _kSwitchHeight = 39.0; -// Label sizes and padding taken from xcode inspector. -// See https://github.com/flutter/flutter/issues/4830#issuecomment-528495360 -const double _kOnLabelWidth = 1.0; -const double _kOnLabelHeight = 10.0; -const double _kOnLabelPaddingHorizontal = 11.0; -const double _kOffLabelWidth = 1.0; -const double _kOffLabelPaddingHorizontal = 12.0; -const double _kOffLabelRadius = 5.0; -const CupertinoDynamicColor _kOffLabelColor = CupertinoDynamicColor.withBrightnessAndContrast( - debugLabel: 'offSwitchLabel', - // Source: https://github.com/flutter/flutter/pull/39993#discussion_r321946033 - color: Color.fromARGB(255, 179, 179, 179), - // Source: https://github.com/flutter/flutter/pull/39993#issuecomment-535196665 - darkColor: Color.fromARGB(255, 179, 179, 179), - // Source: https://github.com/flutter/flutter/pull/127776#discussion_r1244208264 - highContrastColor: Color.fromARGB(255, 255, 255, 255), - darkHighContrastColor: Color.fromARGB(255, 255, 255, 255), -); // Opacity of a disabled switch, as eye-balled from iOS Simulator on Mac. const double _kCupertinoSwitchDisabledOpacity = 0.5; @@ -532,7 +484,6 @@ class _RenderCupertinoSwitch extends RenderConstrainedBox { required TextDirection textDirection, required bool isFocused, required _CupertinoSwitchState state, - required (Color onLabelColor, Color offLabelColor)? onOffLabelColors, }) : _value = value, _activeColor = activeColor, _trackColor = trackColor, @@ -542,7 +493,6 @@ class _RenderCupertinoSwitch extends RenderConstrainedBox { _textDirection = textDirection, _isFocused = isFocused, _state = state, - _onOffLabelColors = onOffLabelColors, super(additionalConstraints: const BoxConstraints.tightFor(width: _kSwitchWidth, height: _kSwitchHeight)) { state.position.addListener(markNeedsPaint); state._reaction.addListener(markNeedsPaint); @@ -634,16 +584,6 @@ class _RenderCupertinoSwitch extends RenderConstrainedBox { markNeedsPaint(); } - (Color onLabelColor, Color offLabelColor)? get onOffLabelColors => _onOffLabelColors; - (Color onLabelColor, Color offLabelColor)? _onOffLabelColors; - set onOffLabelColors((Color onLabelColor, Color offLabelColor)? value) { - if (value == _onOffLabelColors) { - return; - } - _onOffLabelColors = value; - markNeedsPaint(); - } - bool get isInteractive => onChanged != null; @override @@ -709,52 +649,6 @@ class _RenderCupertinoSwitch extends RenderConstrainedBox { canvas.drawRRect(borderTrackRRect, borderPaint); } - if (_onOffLabelColors != null) { - final (Color onLabelColor, Color offLabelColor) = onOffLabelColors!; - - final double leftLabelOpacity = visualPosition * (1.0 - currentReactionValue); - final double rightLabelOpacity = (1.0 - visualPosition) * (1.0 - currentReactionValue); - final (double onLabelOpacity, double offLabelOpacity) = - switch (textDirection) { - TextDirection.ltr => (leftLabelOpacity, rightLabelOpacity), - TextDirection.rtl => (rightLabelOpacity, leftLabelOpacity), - }; - - final (Offset onLabelOffset, Offset offLabelOffset) = - switch (textDirection) { - TextDirection.ltr => ( - trackRect.centerLeft.translate(_kOnLabelPaddingHorizontal, 0), - trackRect.centerRight.translate(-_kOffLabelPaddingHorizontal, 0), - ), - TextDirection.rtl => ( - trackRect.centerRight.translate(-_kOnLabelPaddingHorizontal, 0), - trackRect.centerLeft.translate(_kOffLabelPaddingHorizontal, 0), - ), - }; - - // Draws '|' label - final Rect onLabelRect = Rect.fromCenter( - center: onLabelOffset, - width: _kOnLabelWidth, - height: _kOnLabelHeight, - ); - final Paint onLabelPaint = Paint() - ..color = onLabelColor.withOpacity(onLabelOpacity) - ..style = PaintingStyle.fill; - canvas.drawRect(onLabelRect, onLabelPaint); - - // Draws 'O' label - final Paint offLabelPaint = Paint() - ..color = offLabelColor.withOpacity(offLabelOpacity) - ..style = PaintingStyle.stroke - ..strokeWidth = _kOffLabelWidth; - canvas.drawCircle( - offLabelOffset, - _kOffLabelRadius, - offLabelPaint, - ); - } - final double currentThumbExtension = CupertinoThumbPainter.extension * currentReactionValue; final double thumbLeft = lerpDouble( trackRect.left + _kTrackInnerStart - CupertinoThumbPainter.radius, diff --git a/packages/flutter/lib/src/widgets/media_query.dart b/packages/flutter/lib/src/widgets/media_query.dart index 0714708e14ade..8cd8ee3c2a379 100644 --- a/packages/flutter/lib/src/widgets/media_query.dart +++ b/packages/flutter/lib/src/widgets/media_query.dart @@ -60,8 +60,6 @@ enum _MediaQueryAspect { invertColors, /// Specifies the aspect corresponding to [MediaQueryData.highContrast]. highContrast, - /// Specifies the aspect corresponding to [MediaQueryData.onOffSwitchLabels]. - onOffSwitchLabels, /// Specifies the aspect corresponding to [MediaQueryData.disableAnimations]. disableAnimations, /// Specifies the aspect corresponding to [MediaQueryData.boldText]. @@ -155,7 +153,6 @@ class MediaQueryData { this.accessibleNavigation = false, this.invertColors = false, this.highContrast = false, - this.onOffSwitchLabels = false, this.disableAnimations = false, this.boldText = false, this.navigationMode = NavigationMode.traditional, @@ -223,7 +220,6 @@ class MediaQueryData { disableAnimations = platformData?.disableAnimations ?? view.platformDispatcher.accessibilityFeatures.disableAnimations, boldText = platformData?.boldText ?? view.platformDispatcher.accessibilityFeatures.boldText, highContrast = platformData?.highContrast ?? view.platformDispatcher.accessibilityFeatures.highContrast, - onOffSwitchLabels = platformData?.onOffSwitchLabels ?? view.platformDispatcher.accessibilityFeatures.onOffSwitchLabels, alwaysUse24HourFormat = platformData?.alwaysUse24HourFormat ?? view.platformDispatcher.alwaysUse24HourFormat, navigationMode = platformData?.navigationMode ?? NavigationMode.traditional, gestureSettings = DeviceGestureSettings.fromView(view), @@ -420,15 +416,6 @@ class MediaQueryData { /// or above. final bool highContrast; - /// Whether the user requested to show on/off labels inside switches on iOS, - /// via Settings -> Accessibility -> Display & Text Size -> On/Off Labels. - /// - /// See also: - /// - /// * [dart:ui.PlatformDispatcher.accessibilityFeatures], where the setting - /// originates. - final bool onOffSwitchLabels; - /// Whether the platform is requesting that animations be disabled or reduced /// as much as possible. /// @@ -501,7 +488,6 @@ class MediaQueryData { EdgeInsets? systemGestureInsets, bool? alwaysUse24HourFormat, bool? highContrast, - bool? onOffSwitchLabels, bool? disableAnimations, bool? invertColors, bool? accessibleNavigation, @@ -522,7 +508,6 @@ class MediaQueryData { alwaysUse24HourFormat: alwaysUse24HourFormat ?? this.alwaysUse24HourFormat, invertColors: invertColors ?? this.invertColors, highContrast: highContrast ?? this.highContrast, - onOffSwitchLabels: onOffSwitchLabels ?? this.onOffSwitchLabels, disableAnimations: disableAnimations ?? this.disableAnimations, accessibleNavigation: accessibleNavigation ?? this.accessibleNavigation, boldText: boldText ?? this.boldText, @@ -714,7 +699,6 @@ class MediaQueryData { && other.systemGestureInsets == systemGestureInsets && other.alwaysUse24HourFormat == alwaysUse24HourFormat && other.highContrast == highContrast - && other.onOffSwitchLabels == onOffSwitchLabels && other.disableAnimations == disableAnimations && other.invertColors == invertColors && other.accessibleNavigation == accessibleNavigation @@ -735,7 +719,6 @@ class MediaQueryData { viewInsets, alwaysUse24HourFormat, highContrast, - onOffSwitchLabels, disableAnimations, invertColors, accessibleNavigation, @@ -759,7 +742,6 @@ class MediaQueryData { 'alwaysUse24HourFormat: $alwaysUse24HourFormat', 'accessibleNavigation: $accessibleNavigation', 'highContrast: $highContrast', - 'onOffSwitchLabels: $onOffSwitchLabels', 'disableAnimations: $disableAnimations', 'invertColors: $invertColors', 'boldText: $boldText', @@ -1273,25 +1255,6 @@ class MediaQuery extends InheritedModel<_MediaQueryAspect> { /// the [MediaQueryData.highContrast] property of the ancestor [MediaQuery] changes. static bool? maybeHighContrastOf(BuildContext context) => _maybeOf(context, _MediaQueryAspect.highContrast)?.highContrast; - /// Returns onOffSwitchLabels for the nearest MediaQuery ancestor or false, if no - /// such ancestor exists. - /// - /// See also: - /// - /// * [MediaQueryData.onOffSwitchLabels], which indicates the platform's - /// desire to show on/off labels inside switches. - /// - /// Use of this method will cause the given [context] to rebuild any time that - /// the [MediaQueryData.onOffSwitchLabels] property of the ancestor [MediaQuery] changes. - static bool onOffSwitchLabelsOf(BuildContext context) => maybeOnOffSwitchLabelsOf(context) ?? false; - - /// Returns onOffSwitchLabels for the nearest MediaQuery ancestor or - /// null, if no such ancestor exists. - /// - /// Use of this method will cause the given [context] to rebuild any time that - /// the [MediaQueryData.onOffSwitchLabels] property of the ancestor [MediaQuery] changes. - static bool? maybeOnOffSwitchLabelsOf(BuildContext context) => _maybeOf(context, _MediaQueryAspect.onOffSwitchLabels)?.onOffSwitchLabels; - /// Returns disableAnimations for the nearest MediaQuery ancestor or /// [Brightness.light], if no such ancestor exists. /// @@ -1443,10 +1406,6 @@ class MediaQuery extends InheritedModel<_MediaQueryAspect> { if (data.highContrast != oldWidget.data.highContrast) { return true; } - case _MediaQueryAspect.onOffSwitchLabels: - if (data.onOffSwitchLabels != oldWidget.data.onOffSwitchLabels) { - return true; - } case _MediaQueryAspect.disableAnimations: if (data.disableAnimations != oldWidget.data.disableAnimations) { return true; diff --git a/packages/flutter/test/cupertino/switch_test.dart b/packages/flutter/test/cupertino/switch_test.dart index af9a1336de652..f91fd0af87aa7 100644 --- a/packages/flutter/test/cupertino/switch_test.dart +++ b/packages/flutter/test/cupertino/switch_test.dart @@ -753,187 +753,6 @@ void main() { ); }); - PaintPattern onLabelPaintPattern({ - required int alpha, - bool isRtl = false, - }) => - paints - ..rect( - rect: Rect.fromLTWH(isRtl ? 43.5 : 14.5, 14.5, 1.0, 10.0), - color: const Color(0xffffffff).withAlpha(alpha), - style: PaintingStyle.fill, - ); - - PaintPattern offLabelPaintPattern({ - required int alpha, - bool highContrast = false, - bool isRtl = false, - }) => - paints - ..circle( - x: isRtl ? 16.0 : 43.0, - y: 19.5, - radius: 5.0, - color: - (highContrast ? const Color(0xffffffff) : const Color(0xffb3b3b3)) - .withAlpha(alpha), - strokeWidth: 1.0, - style: PaintingStyle.stroke, - ); - - testWidgets('Switch renders switch labels correctly before, during, and after being tapped', (WidgetTester tester) async { - final Key switchKey = UniqueKey(); - bool value = false; - await tester.pumpWidget( - MediaQuery( - data: const MediaQueryData(onOffSwitchLabels: true), - child: Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return Center( - child: RepaintBoundary( - child: CupertinoSwitch( - key: switchKey, - value: value, - dragStartBehavior: DragStartBehavior.down, - onChanged: (bool newValue) { - setState(() { - value = newValue; - }); - }, - ), - ), - ); - }, - ), - ), - ), - ); - - final RenderObject switchRenderObject = - tester.element(find.byType(CupertinoSwitch)).renderObject!; - - expect(switchRenderObject, offLabelPaintPattern(alpha: 255)); - expect(switchRenderObject, onLabelPaintPattern(alpha: 0)); - - await tester.tap(find.byKey(switchKey)); - expect(value, isTrue); - - // Kick off animation, then advance to intermediate frame. - await tester.pump(); - await tester.pump(const Duration(milliseconds: 60)); - expect(switchRenderObject, onLabelPaintPattern(alpha: 131)); - expect(switchRenderObject, offLabelPaintPattern(alpha: 124)); - - await tester.pumpAndSettle(); - expect(switchRenderObject, onLabelPaintPattern(alpha: 255)); - expect(switchRenderObject, offLabelPaintPattern(alpha: 0)); - }); - - testWidgets('Switch renders switch labels correctly before, during, and after being tapped in high contrast', (WidgetTester tester) async { - final Key switchKey = UniqueKey(); - bool value = false; - await tester.pumpWidget( - MediaQuery( - data: const MediaQueryData( - onOffSwitchLabels: true, - highContrast: true, - ), - child: Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return Center( - child: RepaintBoundary( - child: CupertinoSwitch( - key: switchKey, - value: value, - dragStartBehavior: DragStartBehavior.down, - onChanged: (bool newValue) { - setState(() { - value = newValue; - }); - }, - ), - ), - ); - }, - ), - ), - ), - ); - - final RenderObject switchRenderObject = - tester.element(find.byType(CupertinoSwitch)).renderObject!; - - expect(switchRenderObject, offLabelPaintPattern(highContrast: true, alpha: 255)); - expect(switchRenderObject, onLabelPaintPattern(alpha: 0)); - - await tester.tap(find.byKey(switchKey)); - expect(value, isTrue); - - // Kick off animation, then advance to intermediate frame. - await tester.pump(); - await tester.pump(const Duration(milliseconds: 60)); - expect(switchRenderObject, onLabelPaintPattern(alpha: 131)); - expect(switchRenderObject, offLabelPaintPattern(highContrast: true, alpha: 124)); - - await tester.pumpAndSettle(); - expect(switchRenderObject, onLabelPaintPattern(alpha: 255)); - expect(switchRenderObject, offLabelPaintPattern(highContrast: true, alpha: 0)); - }); - - testWidgets('Switch renders switch labels correctly before, during, and after being tapped with direction rtl', (WidgetTester tester) async { - final Key switchKey = UniqueKey(); - bool value = false; - await tester.pumpWidget( - MediaQuery( - data: const MediaQueryData(onOffSwitchLabels: true), - child: Directionality( - textDirection: TextDirection.rtl, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return Center( - child: RepaintBoundary( - child: CupertinoSwitch( - key: switchKey, - value: value, - dragStartBehavior: DragStartBehavior.down, - onChanged: (bool newValue) { - setState(() { - value = newValue; - }); - }, - ), - ), - ); - }, - ), - ), - ), - ); - - final RenderObject switchRenderObject = - tester.element(find.byType(CupertinoSwitch)).renderObject!; - - expect(switchRenderObject, offLabelPaintPattern(isRtl: true, alpha: 255)); - expect(switchRenderObject, onLabelPaintPattern(isRtl: true, alpha: 0)); - - await tester.tap(find.byKey(switchKey)); - expect(value, isTrue); - - // Kick off animation, then advance to intermediate frame. - await tester.pump(); - await tester.pump(const Duration(milliseconds: 60)); - expect(switchRenderObject, onLabelPaintPattern(isRtl: true, alpha: 131)); - expect(switchRenderObject, offLabelPaintPattern(isRtl: true, alpha: 124)); - - await tester.pumpAndSettle(); - expect(switchRenderObject, onLabelPaintPattern(isRtl: true, alpha: 255)); - expect(switchRenderObject, offLabelPaintPattern(isRtl: true, alpha: 0)); - }); - testWidgets('Switch renders correctly in dark mode', (WidgetTester tester) async { final Key switchKey = UniqueKey(); bool value = false; diff --git a/packages/flutter/test/widgets/media_query_test.dart b/packages/flutter/test/widgets/media_query_test.dart index 135f284ac9690..9516e35e2fdc3 100644 --- a/packages/flutter/test/widgets/media_query_test.dart +++ b/packages/flutter/test/widgets/media_query_test.dart @@ -154,7 +154,6 @@ void main() { expect(data.disableAnimations, false); expect(data.boldText, false); expect(data.highContrast, false); - expect(data.onOffSwitchLabels, false); expect(data.platformBrightness, Brightness.light); expect(data.gestureSettings.touchSlop, null); expect(data.displayFeatures, isEmpty); @@ -169,7 +168,6 @@ void main() { disableAnimations: true, boldText: true, highContrast: true, - onOffSwitchLabels: true, alwaysUse24HourFormat: true, navigationMode: NavigationMode.directional, ); @@ -190,7 +188,6 @@ void main() { expect(data.disableAnimations, platformData.disableAnimations); expect(data.boldText, platformData.boldText); expect(data.highContrast, platformData.highContrast); - expect(data.onOffSwitchLabels, platformData.onOffSwitchLabels); expect(data.alwaysUse24HourFormat, platformData.alwaysUse24HourFormat); expect(data.navigationMode, platformData.navigationMode); expect(data.gestureSettings, DeviceGestureSettings.fromView(tester.view)); @@ -220,7 +217,6 @@ void main() { expect(data.disableAnimations, tester.platformDispatcher.accessibilityFeatures.disableAnimations); expect(data.boldText, tester.platformDispatcher.accessibilityFeatures.boldText); expect(data.highContrast, tester.platformDispatcher.accessibilityFeatures.highContrast); - expect(data.onOffSwitchLabels, tester.platformDispatcher.accessibilityFeatures.onOffSwitchLabels); expect(data.alwaysUse24HourFormat, tester.platformDispatcher.alwaysUse24HourFormat); expect(data.navigationMode, NavigationMode.traditional); expect(data.gestureSettings, DeviceGestureSettings.fromView(tester.view)); @@ -236,7 +232,6 @@ void main() { disableAnimations: true, boldText: true, highContrast: true, - onOffSwitchLabels: true, alwaysUse24HourFormat: true, navigationMode: NavigationMode.directional, ); @@ -269,7 +264,6 @@ void main() { expect(data.disableAnimations, platformData.disableAnimations); expect(data.boldText, platformData.boldText); expect(data.highContrast, platformData.highContrast); - expect(data.onOffSwitchLabels, platformData.onOffSwitchLabels); expect(data.alwaysUse24HourFormat, platformData.alwaysUse24HourFormat); expect(data.navigationMode, platformData.navigationMode); expect(data.gestureSettings, DeviceGestureSettings.fromView(tester.view)); @@ -317,7 +311,6 @@ void main() { expect(data.disableAnimations, tester.platformDispatcher.accessibilityFeatures.disableAnimations); expect(data.boldText, tester.platformDispatcher.accessibilityFeatures.boldText); expect(data.highContrast, tester.platformDispatcher.accessibilityFeatures.highContrast); - expect(data.onOffSwitchLabels, tester.platformDispatcher.accessibilityFeatures.onOffSwitchLabels); expect(data.alwaysUse24HourFormat, tester.platformDispatcher.alwaysUse24HourFormat); expect(data.navigationMode, NavigationMode.traditional); expect(data.gestureSettings, DeviceGestureSettings.fromView(tester.view)); @@ -496,7 +489,6 @@ void main() { expect(copied.disableAnimations, data.disableAnimations); expect(copied.boldText, data.boldText); expect(copied.highContrast, data.highContrast); - expect(copied.onOffSwitchLabels, data.onOffSwitchLabels); expect(copied.platformBrightness, data.platformBrightness); expect(copied.gestureSettings, data.gestureSettings); expect(copied.displayFeatures, data.displayFeatures); @@ -536,7 +528,6 @@ void main() { disableAnimations: true, boldText: true, highContrast: true, - onOffSwitchLabels: true, platformBrightness: Brightness.dark, navigationMode: NavigationMode.directional, gestureSettings: gestureSettings, @@ -555,7 +546,6 @@ void main() { expect(copied.disableAnimations, true); expect(copied.boldText, true); expect(copied.highContrast, true); - expect(copied.onOffSwitchLabels, true); expect(copied.platformBrightness, Brightness.dark); expect(copied.navigationMode, NavigationMode.directional); expect(copied.gestureSettings, gestureSettings); @@ -593,7 +583,6 @@ void main() { disableAnimations: true, boldText: true, highContrast: true, - onOffSwitchLabels: true, navigationMode: NavigationMode.directional, displayFeatures: displayFeatures, ), @@ -629,7 +618,6 @@ void main() { expect(unpadded.disableAnimations, true); expect(unpadded.boldText, true); expect(unpadded.highContrast, true); - expect(unpadded.onOffSwitchLabels, true); expect(unpadded.navigationMode, NavigationMode.directional); expect(unpadded.displayFeatures, displayFeatures); }); @@ -665,7 +653,6 @@ void main() { disableAnimations: true, boldText: true, highContrast: true, - onOffSwitchLabels: true, navigationMode: NavigationMode.directional, displayFeatures: displayFeatures, ), @@ -698,7 +685,6 @@ void main() { expect(unpadded.disableAnimations, true); expect(unpadded.boldText, true); expect(unpadded.highContrast, true); - expect(unpadded.onOffSwitchLabels, true); expect(unpadded.navigationMode, NavigationMode.directional); expect(unpadded.displayFeatures, displayFeatures); }); @@ -734,7 +720,6 @@ void main() { disableAnimations: true, boldText: true, highContrast: true, - onOffSwitchLabels: true, navigationMode: NavigationMode.directional, displayFeatures: displayFeatures, ), @@ -770,7 +755,6 @@ void main() { expect(unpadded.disableAnimations, true); expect(unpadded.boldText, true); expect(unpadded.highContrast, true); - expect(unpadded.onOffSwitchLabels, true); expect(unpadded.navigationMode, NavigationMode.directional); expect(unpadded.displayFeatures, displayFeatures); }); @@ -806,7 +790,6 @@ void main() { disableAnimations: true, boldText: true, highContrast: true, - onOffSwitchLabels: true, navigationMode: NavigationMode.directional, displayFeatures: displayFeatures, ), @@ -839,7 +822,6 @@ void main() { expect(unpadded.disableAnimations, true); expect(unpadded.boldText, true); expect(unpadded.highContrast, true); - expect(unpadded.onOffSwitchLabels, true); expect(unpadded.navigationMode, NavigationMode.directional); expect(unpadded.displayFeatures, displayFeatures); }); @@ -875,7 +857,6 @@ void main() { disableAnimations: true, boldText: true, highContrast: true, - onOffSwitchLabels: true, navigationMode: NavigationMode.directional, displayFeatures: displayFeatures, ), @@ -911,7 +892,6 @@ void main() { expect(unpadded.disableAnimations, true); expect(unpadded.boldText, true); expect(unpadded.highContrast, true); - expect(unpadded.onOffSwitchLabels, true); expect(unpadded.navigationMode, NavigationMode.directional); expect(unpadded.displayFeatures, displayFeatures); }); @@ -947,7 +927,6 @@ void main() { disableAnimations: true, boldText: true, highContrast: true, - onOffSwitchLabels: true, navigationMode: NavigationMode.directional, displayFeatures: displayFeatures, ), @@ -980,7 +959,6 @@ void main() { expect(unpadded.disableAnimations, true); expect(unpadded.boldText, true); expect(unpadded.highContrast, true); - expect(unpadded.onOffSwitchLabels, true); expect(unpadded.navigationMode, NavigationMode.directional); expect(unpadded.displayFeatures, displayFeatures); }); @@ -1066,33 +1044,6 @@ void main() { expect(insideHighContrast, true); }); - testWidgets('MediaQuery.onOffSwitchLabelsOf', (WidgetTester tester) async { - late bool outsideOnOffSwitchLabels; - late bool insideOnOffSwitchLabels; - - await tester.pumpWidget( - Builder( - builder: (BuildContext context) { - outsideOnOffSwitchLabels = MediaQuery.onOffSwitchLabelsOf(context); - return MediaQuery( - data: const MediaQueryData( - onOffSwitchLabels: true, - ), - child: Builder( - builder: (BuildContext context) { - insideOnOffSwitchLabels = MediaQuery.onOffSwitchLabelsOf(context); - return Container(); - }, - ), - ); - }, - ), - ); - - expect(outsideOnOffSwitchLabels, false); - expect(insideOnOffSwitchLabels, true); - }); - testWidgets('MediaQuery.boldTextOf', (WidgetTester tester) async { late bool outsideBoldTextOverride; late bool insideBoldTextOverride; @@ -1220,7 +1171,6 @@ void main() { disableAnimations: true, boldText: true, highContrast: true, - onOffSwitchLabels: true, displayFeatures: displayFeatures, ), child: Builder( @@ -1251,7 +1201,6 @@ void main() { expect(subScreenMediaQuery.disableAnimations, true); expect(subScreenMediaQuery.boldText, true); expect(subScreenMediaQuery.highContrast, true); - expect(subScreenMediaQuery.onOffSwitchLabels, true); expect(subScreenMediaQuery.displayFeatures, isEmpty); }); @@ -1295,7 +1244,6 @@ void main() { disableAnimations: true, boldText: true, highContrast: true, - onOffSwitchLabels: true, displayFeatures: displayFeatures, ), child: Builder( @@ -1335,7 +1283,6 @@ void main() { expect(subScreenMediaQuery.disableAnimations, true); expect(subScreenMediaQuery.boldText, true); expect(subScreenMediaQuery.highContrast, true); - expect(subScreenMediaQuery.onOffSwitchLabels, true); expect(subScreenMediaQuery.displayFeatures, [cutoutDisplayFeature]); }); @@ -1506,8 +1453,6 @@ void main() { const _MediaQueryAspectCase(MediaQuery.maybeInvertColorsOf, MediaQueryData(invertColors: true)), const _MediaQueryAspectCase(MediaQuery.highContrastOf, MediaQueryData(highContrast: true)), const _MediaQueryAspectCase(MediaQuery.maybeHighContrastOf, MediaQueryData(highContrast: true)), - const _MediaQueryAspectCase(MediaQuery.onOffSwitchLabelsOf, MediaQueryData(onOffSwitchLabels: true)), - const _MediaQueryAspectCase(MediaQuery.maybeOnOffSwitchLabelsOf, MediaQueryData(onOffSwitchLabels: true)), const _MediaQueryAspectCase(MediaQuery.disableAnimationsOf, MediaQueryData(disableAnimations: true)), const _MediaQueryAspectCase(MediaQuery.maybeDisableAnimationsOf, MediaQueryData(disableAnimations: true)), const _MediaQueryAspectCase(MediaQuery.boldTextOf, MediaQueryData(boldText: true)), From 1f6bdb6fa288a8163813571573e517a47bc86264 Mon Sep 17 00:00:00 2001 From: Casey Hillers Date: Tue, 11 Jul 2023 14:31:59 -0700 Subject: [PATCH 03/58] [flutter_releases] Flutter beta 3.13.0-0.1.pre Framework Cherrypicks (#130353) # Flutter beta 3.13.0-0.1.pre Framework ## Scheduled Cherrypicks --- bin/internal/engine.version | 2 +- bin/internal/release-candidate-branch.version | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 bin/internal/release-candidate-branch.version diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 7d0d48483dd33..82e8ec84a8b96 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -bd2e42b203e14507055704d06c9ac0c6f50c2f01 +ae470c12d363f9b7c6ace259891b783b0fab64dc diff --git a/bin/internal/release-candidate-branch.version b/bin/internal/release-candidate-branch.version new file mode 100644 index 0000000000000..e9546287aea5d --- /dev/null +++ b/bin/internal/release-candidate-branch.version @@ -0,0 +1 @@ +flutter-3.13-candidate.0 From 1dc07d26fc1e82040d0cdae2db38a888a9213459 Mon Sep 17 00:00:00 2001 From: Christopher Fujino Date: Tue, 18 Jul 2023 10:16:23 -0700 Subject: [PATCH 04/58] [CP] Fix ConcurrentModificationError in DDS (#130740) Fixes https://github.com/flutter/flutter/issues/130739 --- packages/flutter_tools/pubspec.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/flutter_tools/pubspec.yaml b/packages/flutter_tools/pubspec.yaml index 0ed5c487cb648..48c32b376abe3 100644 --- a/packages/flutter_tools/pubspec.yaml +++ b/packages/flutter_tools/pubspec.yaml @@ -10,7 +10,7 @@ dependencies: archive: 3.3.2 args: 2.4.2 browser_launcher: 1.1.1 - dds: 2.9.0 + dds: 2.9.0+hotfix dwds: 19.0.1 completion: 1.0.1 coverage: 1.6.3 @@ -106,4 +106,4 @@ dartdoc: # Exclude this package from the hosted API docs. nodoc: true -# PUBSPEC CHECKSUM: d197 +# PUBSPEC CHECKSUM: 3b57 From ac71592bc605996f9376ec5b7607db52b64cabd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20S=20Guerrero?= Date: Tue, 18 Jul 2023 14:53:57 -0600 Subject: [PATCH 05/58] [flutter_releases] Flutter beta 3.13.0-0.2.pre Framework Cherrypicks (#130822) # Flutter beta 3.13.0-0.2.pre Framework ## Scheduled Cherrypicks --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 82e8ec84a8b96..95cee72cd394f 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -ae470c12d363f9b7c6ace259891b783b0fab64dc +e14db68a86199a9fa5fd9628d8e290aafc0f6917 From 945b12da6a526fb6b7f273e85c6bdf92c6ead7c4 Mon Sep 17 00:00:00 2001 From: Kate Lovett Date: Tue, 1 Aug 2023 14:34:22 -0500 Subject: [PATCH 06/58] [CP] Minor adjustments on 2D APIs (#131358) (#131436) These tweaks came from https://github.com/flutter/packages/pull/4536 - The TwoDimensionalChildBuilderDelegate asserts that maxXIndex and maxYIndex are null or >= 0 - The TwoDimensionalChildDelegate setter in RenderTwoDimensionalViewport has a covariant to allow type safety for subclasses of RenderTwoDimensionalViewport implementing with other subclasses of TwoDimensionalChildDelegate I'd like to cherry pick this so https://github.com/flutter/packages/pull/4536 will not have to wait for it to reach stable. --- .../lib/src/widgets/scroll_delegate.dart | 8 +- .../src/widgets/two_dimensional_viewport.dart | 2 +- .../two_dimensional_viewport_test.dart | 97 +++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/packages/flutter/lib/src/widgets/scroll_delegate.dart b/packages/flutter/lib/src/widgets/scroll_delegate.dart index 28f9e28e13a3a..765bf31d8a1a7 100644 --- a/packages/flutter/lib/src/widgets/scroll_delegate.dart +++ b/packages/flutter/lib/src/widgets/scroll_delegate.dart @@ -933,7 +933,9 @@ class TwoDimensionalChildBuilderDelegate extends TwoDimensionalChildDelegate { required this.builder, int? maxXIndex, int? maxYIndex, - }) : _maxYIndex = maxYIndex, + }) : assert(maxYIndex == null || maxYIndex >= 0), + assert(maxXIndex == null || maxXIndex >= 0), + _maxYIndex = maxYIndex, _maxXIndex = maxXIndex; /// Called to build children on demand. @@ -974,6 +976,8 @@ class TwoDimensionalChildBuilderDelegate extends TwoDimensionalChildDelegate { /// [TwoDimensionalViewport] subclass to learn how this value is applied in /// the specific use case. /// + /// If not null, the value must be non-negative. + /// /// If the value changes, the delegate will call [notifyListeners]. This /// informs the [RenderTwoDimensionalViewport] that any cached information /// from the delegate is invalid. @@ -993,6 +997,7 @@ class TwoDimensionalChildBuilderDelegate extends TwoDimensionalChildDelegate { if (value == maxXIndex) { return; } + assert(value == null || value >= 0); _maxXIndex = value; notifyListeners(); } @@ -1015,6 +1020,7 @@ class TwoDimensionalChildBuilderDelegate extends TwoDimensionalChildDelegate { if (maxYIndex == value) { return; } + assert(value == null || value >= 0); _maxYIndex = value; notifyListeners(); } diff --git a/packages/flutter/lib/src/widgets/two_dimensional_viewport.dart b/packages/flutter/lib/src/widgets/two_dimensional_viewport.dart index 69976eb1e48c7..82befbce08432 100644 --- a/packages/flutter/lib/src/widgets/two_dimensional_viewport.dart +++ b/packages/flutter/lib/src/widgets/two_dimensional_viewport.dart @@ -612,7 +612,7 @@ abstract class RenderTwoDimensionalViewport extends RenderBox implements RenderA /// Supplies children for layout in the viewport. TwoDimensionalChildDelegate get delegate => _delegate; TwoDimensionalChildDelegate _delegate; - set delegate(TwoDimensionalChildDelegate value) { + set delegate(covariant TwoDimensionalChildDelegate value) { if (_delegate == value) { return; } diff --git a/packages/flutter/test/widgets/two_dimensional_viewport_test.dart b/packages/flutter/test/widgets/two_dimensional_viewport_test.dart index b44fac359e337..a445d3412c97b 100644 --- a/packages/flutter/test/widgets/two_dimensional_viewport_test.dart +++ b/packages/flutter/test/widgets/two_dimensional_viewport_test.dart @@ -111,6 +111,78 @@ void main() { ); }, variant: TargetPlatformVariant.all()); + test('maxXIndex and maxYIndex assertions', () { + final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate( + maxXIndex: 0, + maxYIndex: 0, + builder: (BuildContext context, ChildVicinity vicinity) { + return const SizedBox.shrink(); + } + ); + // Update + expect( + () { + delegate.maxXIndex = -1; + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains('value == null || value >= 0'), + ), + ), + ); + expect( + () { + delegate.maxYIndex = -1; + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains('value == null || value >= 0'), + ), + ), + ); + // Constructor + expect( + () { + TwoDimensionalChildBuilderDelegate( + maxXIndex: -1, + maxYIndex: 0, + builder: (BuildContext context, ChildVicinity vicinity) { + return const SizedBox.shrink(); + } + ); + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains('maxXIndex == null || maxXIndex >= 0'), + ), + ), + ); + expect( + () { + TwoDimensionalChildBuilderDelegate( + maxXIndex: 0, + maxYIndex: -1, + builder: (BuildContext context, ChildVicinity vicinity) { + return const SizedBox.shrink(); + } + ); + }, + throwsA( + isA().having( + (AssertionError error) => error.toString(), + 'description', + contains('maxYIndex == null || maxYIndex >= 0'), + ), + ), + ); + }); + testWidgets('throws an error when builder throws', (WidgetTester tester) async { final List exceptions = []; final FlutterExceptionHandler? oldHandler = FlutterError.onError; @@ -1822,3 +1894,28 @@ Future restoreScrollAndVerify(WidgetTester tester) async { 100.0, ); } + +// Validates covariant through analysis. +mixin _SomeDelegateMixin on TwoDimensionalChildDelegate {} + +class _SomeRenderTwoDimensionalViewport extends RenderTwoDimensionalViewport { // ignore: unused_element + _SomeRenderTwoDimensionalViewport({ + required super.horizontalOffset, + required super.horizontalAxisDirection, + required super.verticalOffset, + required super.verticalAxisDirection, + required _SomeDelegateMixin super.delegate, + required super.mainAxis, + required super.childManager, + }); + + @override + _SomeDelegateMixin get delegate => super.delegate as _SomeDelegateMixin; + @override + set delegate(_SomeDelegateMixin value) { // Analysis would fail without covariant + super.delegate = value; + } + + @override + void layoutChildSequence() {} +} From 1f0b77f3fda92d4aa4b14f7c3162008c7920b728 Mon Sep 17 00:00:00 2001 From: Xilai Zhang Date: Thu, 3 Aug 2023 09:52:29 -0700 Subject: [PATCH 07/58] [flutter_releases] Flutter beta 3.13.0-0.3.pre Framework Cherrypicks (#131857) # Flutter beta 3.13.0-0.3.pre Framework ## Scheduled Cherrypicks --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 95cee72cd394f..341646c46367f 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -e14db68a86199a9fa5fd9628d8e290aafc0f6917 +75684126921c9b5040edc1ab7e6ba03f8af221ee From e90f5d4561661f992387ca6187ff16504873334a Mon Sep 17 00:00:00 2001 From: Xilai Zhang Date: Fri, 4 Aug 2023 14:55:33 -0700 Subject: [PATCH 08/58] [flutter release] manual roll engine (#131958) context: https://chat.google.com/room/AAAAc_4rqiI/pl4B0W45bLA redo engine->framework. for 3.13.0, the tot engine commit does not align with engine version in framework. this causes packaging script to crash. --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 341646c46367f..ae36c7a225bbe 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -75684126921c9b5040edc1ab7e6ba03f8af221ee +c1f977f72b68da673ec493ab5df0beacacc7a88b From 5c4bfbcc858df049a197a4458b2e6725451df8d2 Mon Sep 17 00:00:00 2001 From: LongCatIsLooong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Mon, 7 Aug 2023 15:23:16 -0700 Subject: [PATCH 09/58] [CP] Allow `OverlayPortal` to be added/removed from the tree in a layout callback (#130670) (#131290) CP https://github.com/flutter/flutter/issues/131002 --- packages/flutter/lib/src/widgets/overlay.dart | 12 +--- .../test/widgets/overlay_portal_test.dart | 57 +++++++++++++++++++ 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/packages/flutter/lib/src/widgets/overlay.dart b/packages/flutter/lib/src/widgets/overlay.dart index c3e71dfcafead..562f6d86b1139 100644 --- a/packages/flutter/lib/src/widgets/overlay.dart +++ b/packages/flutter/lib/src/widgets/overlay.dart @@ -936,14 +936,6 @@ class _RenderTheater extends RenderBox with ContainerRenderObjectMixin visitChildren(redepthChild); - void _adoptDeferredLayoutBoxChild(_RenderDeferredLayoutBox child) { - adoptChild(child); - } - - void _dropDeferredLayoutBoxChild(_RenderDeferredLayoutBox child) { - dropChild(child); - } - Alignment? _alignmentCache; Alignment get _resolvedAlignment => _alignmentCache ??= AlignmentDirectional.topStart.resolve(textDirection); @@ -1703,13 +1695,13 @@ final class _OverlayEntryLocation extends LinkedListEntry<_OverlayEntryLocation> // This call is allowed even when this location is invalidated. // See _OverlayPortalElement.activate. assert(_overlayChildRenderBox == null, '$_overlayChildRenderBox'); - _theater._adoptDeferredLayoutBoxChild(child); + _theater._addDeferredChild(child); _overlayChildRenderBox = child; } void _deactivate(_RenderDeferredLayoutBox child) { // This call is allowed even when this location is invalidated. - _theater._dropDeferredLayoutBoxChild(child); + _theater._removeDeferredChild(child); _overlayChildRenderBox = null; } diff --git a/packages/flutter/test/widgets/overlay_portal_test.dart b/packages/flutter/test/widgets/overlay_portal_test.dart index 1d90ecf1b7f77..702a5db64058d 100644 --- a/packages/flutter/test/widgets/overlay_portal_test.dart +++ b/packages/flutter/test/widgets/overlay_portal_test.dart @@ -655,6 +655,63 @@ void main() { verifyTreeIsClean(); }); + testWidgets('Adding/Removing OverlayPortal in LayoutBuilder during layout', (WidgetTester tester) async { + final GlobalKey widgetKey = GlobalKey(debugLabel: 'widget'); + final GlobalKey overlayKey = GlobalKey(debugLabel: 'overlay'); + controller1.hide(); + late StateSetter setState; + Size size = Size.zero; + + final Widget overlayPortal = OverlayPortal( + key: widgetKey, + controller: controller1, + overlayChildBuilder: (BuildContext context) => const Placeholder(), + child: const Placeholder(), + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Overlay( + key: overlayKey, + initialEntries: [ + OverlayEntry( + builder: (BuildContext context) { + return StatefulBuilder( + builder: (BuildContext context, StateSetter stateSetter) { + setState = stateSetter; + return Center( + child: SizedBox.fromSize( + size: size, + child: LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) { + // This layout callback adds/removes an OverlayPortal during layout. + return constraints.maxHeight > 0 ? overlayPortal : const SizedBox(); + }), + ), + ); + } + ); + } + ), + ], + ), + ), + ); + controller1.show(); + await tester.pump(); + expect(tester.takeException(), isNull); + + // Adds the OverlayPortal from within a LayoutBuilder, in a layout callback. + setState(() { size = const Size(300, 300); }); + await tester.pump(); + expect(tester.takeException(), isNull); + + // Removes the OverlayPortal from within a LayoutBuilder, in a layout callback. + setState(() { size = Size.zero; }); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + testWidgets('Change overlay constraints', (WidgetTester tester) async { final GlobalKey widgetKey = GlobalKey(debugLabel: 'widget outer'); final GlobalKey overlayKey = GlobalKey(debugLabel: 'overlay'); From f4c42610bb3ef3b05e25078c54869ec7f2e8c27b Mon Sep 17 00:00:00 2001 From: LongCatIsLooong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Mon, 7 Aug 2023 15:23:17 -0700 Subject: [PATCH 10/58] [CP] `_RenderScaledInlineWidget` constrains child size (#130648) (#131289) CP: https://github.com/flutter/flutter/issues/131004 --- .../flutter/lib/src/widgets/widget_span.dart | 2 +- packages/flutter/test/widgets/text_test.dart | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/flutter/lib/src/widgets/widget_span.dart b/packages/flutter/lib/src/widgets/widget_span.dart index b0e5ad99b3be3..776d4087ceff2 100644 --- a/packages/flutter/lib/src/widgets/widget_span.dart +++ b/packages/flutter/lib/src/widgets/widget_span.dart @@ -375,7 +375,7 @@ class _RenderScaledInlineWidget extends RenderBox with RenderObjectWithChildMixi // Only constrain the width to the maximum width of the paragraph. // Leave height unconstrained, which will overflow if expanded past. child.layout(BoxConstraints(maxWidth: constraints.maxWidth / scale), parentUsesSize: true); - size = child.size * scale; + size = constraints.constrain(child.size * scale); } @override diff --git a/packages/flutter/test/widgets/text_test.dart b/packages/flutter/test/widgets/text_test.dart index ea3202509c8cf..0289a64d20b0c 100644 --- a/packages/flutter/test/widgets/text_test.dart +++ b/packages/flutter/test/widgets/text_test.dart @@ -266,6 +266,23 @@ void main() { expect(renderText.size.height, singleLineHeight * textScaleFactor * 3); }); + testWidgets("Inline widgets' scaled sizes are constrained", (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/130588 + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 100.3, + child: Text.rich(WidgetSpan(child: Row()), textScaleFactor: 0.3), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + testWidgets('semanticsLabel can override text label', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); await tester.pumpWidget( From 85127f4f943c5e18cf63a03bdf07bf7b3c087a11 Mon Sep 17 00:00:00 2001 From: LongCatIsLooong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Mon, 7 Aug 2023 15:25:45 -0700 Subject: [PATCH 11/58] [Cp] Fix Tooltip crash when selected in a SelectableRegion (#130181) (#131288) CP: https://github.com/flutter/flutter/issues/131005 --- .../lib/src/material/selection_area.dart | 28 ++---- .../flutter/lib/src/material/tooltip.dart | 19 +++- packages/flutter/lib/src/widgets/overlay.dart | 1 + .../flutter/test/material/tooltip_test.dart | 91 +++++++++++++++++++ 4 files changed, 115 insertions(+), 24 deletions(-) diff --git a/packages/flutter/lib/src/material/selection_area.dart b/packages/flutter/lib/src/material/selection_area.dart index 625f3aa1afaab..2334f545cc3bf 100644 --- a/packages/flutter/lib/src/material/selection_area.dart +++ b/packages/flutter/lib/src/material/selection_area.dart @@ -103,13 +103,7 @@ class SelectionArea extends StatefulWidget { } class _SelectionAreaState extends State { - FocusNode get _effectiveFocusNode { - if (widget.focusNode != null) { - return widget.focusNode!; - } - _internalNode ??= FocusNode(); - return _internalNode!; - } + FocusNode get _effectiveFocusNode => widget.focusNode ?? (_internalNode ??= FocusNode()); FocusNode? _internalNode; @override @@ -121,20 +115,12 @@ class _SelectionAreaState extends State { @override Widget build(BuildContext context) { assert(debugCheckHasMaterialLocalizations(context)); - TextSelectionControls? controls = widget.selectionControls; - switch (Theme.of(context).platform) { - case TargetPlatform.android: - case TargetPlatform.fuchsia: - controls ??= materialTextSelectionHandleControls; - case TargetPlatform.iOS: - controls ??= cupertinoTextSelectionHandleControls; - case TargetPlatform.linux: - case TargetPlatform.windows: - controls ??= desktopTextSelectionHandleControls; - case TargetPlatform.macOS: - controls ??= cupertinoDesktopTextSelectionHandleControls; - } - + final TextSelectionControls controls = widget.selectionControls ?? switch (Theme.of(context).platform) { + TargetPlatform.android || TargetPlatform.fuchsia => materialTextSelectionHandleControls, + TargetPlatform.linux || TargetPlatform.windows => desktopTextSelectionHandleControls, + TargetPlatform.iOS => cupertinoTextSelectionHandleControls, + TargetPlatform.macOS => cupertinoDesktopTextSelectionHandleControls, + }; return SelectableRegion( selectionControls: controls, focusNode: _effectiveFocusNode, diff --git a/packages/flutter/lib/src/material/tooltip.dart b/packages/flutter/lib/src/material/tooltip.dart index 045d97d767c2f..2be81e1ba51cc 100644 --- a/packages/flutter/lib/src/material/tooltip.dart +++ b/packages/flutter/lib/src/material/tooltip.dart @@ -482,13 +482,16 @@ class TooltipState extends State with SingleTickerProviderStateMixin { void _scheduleDismissTooltip({ required Duration withDelay }) { assert(mounted); assert( - !(_timer?.isActive ?? false) || _controller.status != AnimationStatus.reverse, + !(_timer?.isActive ?? false) || _backingController?.status != AnimationStatus.reverse, 'timer must not be active when the tooltip is fading out', ); _timer?.cancel(); _timer = null; - switch (_controller.status) { + // Use _backingController instead of _controller to prevent the lazy getter + // from instaniating an AnimationController unnecessarily. + switch (_backingController?.status) { + case null: case AnimationStatus.reverse: case AnimationStatus.dismissed: break; @@ -740,7 +743,7 @@ class TooltipState extends State with SingleTickerProviderStateMixin { }; final TooltipThemeData tooltipTheme = _tooltipTheme; - return _TooltipOverlay( + final _TooltipOverlay overlayChild = _TooltipOverlay( richMessage: widget.richMessage ?? TextSpan(text: widget.message), height: widget.height ?? tooltipTheme.height ?? _getDefaultTooltipHeight(), padding: widget.padding ?? tooltipTheme.padding ?? _getDefaultPadding(), @@ -755,13 +758,23 @@ class TooltipState extends State with SingleTickerProviderStateMixin { verticalOffset: widget.verticalOffset ?? tooltipTheme.verticalOffset ?? _defaultVerticalOffset, preferBelow: widget.preferBelow ?? tooltipTheme.preferBelow ?? _defaultPreferBelow, ); + + return SelectionContainer.maybeOf(context) == null + ? overlayChild + : SelectionContainer.disabled(child: overlayChild); } @override void dispose() { GestureBinding.instance.pointerRouter.removeGlobalRoute(_handleGlobalPointerEvent); Tooltip._openedTooltips.remove(this); + // _longPressRecognizer.dispose() and _tapRecognizer.dispose() may call + // their registered onCancel callbacks if there's a gesture in progress. + // Remove the onCancel callbacks to prevent the registered callbacks from + // triggering unnecessary side effects (such as animations). + _longPressRecognizer?.onLongPressCancel = null; _longPressRecognizer?.dispose(); + _tapRecognizer?.onTapCancel = null; _tapRecognizer?.dispose(); _timer?.cancel(); _backingController?.dispose(); diff --git a/packages/flutter/lib/src/widgets/overlay.dart b/packages/flutter/lib/src/widgets/overlay.dart index 562f6d86b1139..04c71fab73f0e 100644 --- a/packages/flutter/lib/src/widgets/overlay.dart +++ b/packages/flutter/lib/src/widgets/overlay.dart @@ -1561,6 +1561,7 @@ class _OverlayPortalState extends State { @override void dispose() { + assert(widget.controller._attachTarget == this); widget.controller._attachTarget = null; _locationCache?._debugMarkLocationInvalid(); _locationCache = null; diff --git a/packages/flutter/test/material/tooltip_test.dart b/packages/flutter/test/material/tooltip_test.dart index 544f88324720f..84d637190d762 100644 --- a/packages/flutter/test/material/tooltip_test.dart +++ b/packages/flutter/test/material/tooltip_test.dart @@ -2278,6 +2278,97 @@ void main() { await tester.pump(const Duration(seconds: 1)); expect(element.dirty, isFalse); }); + + testWidgets('Tooltip does not initialize animation controller in dispose process', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: Tooltip( + message: tooltipText, + waitDuration: Duration(seconds: 1), + triggerMode: TooltipTriggerMode.longPress, + child: SizedBox.square(dimension: 50), + ), + ), + ), + ); + + await tester.startGesture(tester.getCenter(find.byType(Tooltip))); + await tester.pumpWidget(const SizedBox()); + expect(tester.takeException(), isNull); + }); + + testWidgets('Tooltip does not crash when showing the tooltip but the OverlayPortal is unmounted, during dispose', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: SelectionArea( + child: Center( + child: Tooltip( + message: tooltipText, + waitDuration: Duration(seconds: 1), + triggerMode: TooltipTriggerMode.longPress, + child: SizedBox.square(dimension: 50), + ), + ), + ), + ), + ); + + final TooltipState tooltipState = tester.state(find.byType(Tooltip)); + await tester.startGesture(tester.getCenter(find.byType(Tooltip))); + tooltipState.ensureTooltipVisible(); + await tester.pumpWidget(const SizedBox()); + expect(tester.takeException(), isNull); + }); + + testWidgets('Tooltip is not selectable', (WidgetTester tester) async { + const String tooltipText = 'AAAAAAAAAAAAAAAAAAAAAAA'; + String? selectedText; + await tester.pumpWidget( + MaterialApp( + home: SelectionArea( + onSelectionChanged: (SelectedContent? content) { selectedText = content?.plainText; }, + child: const Center( + child: Column( + children: [ + Text('Select Me'), + Tooltip( + message: tooltipText, + waitDuration: Duration(seconds: 1), + triggerMode: TooltipTriggerMode.longPress, + child: SizedBox.square(dimension: 50), + ), + ], + ), + ), + ), + ), + ); + + final TooltipState tooltipState = tester.state(find.byType(Tooltip)); + + final Rect textRect = tester.getRect(find.text('Select Me')); + final TestGesture gesture = await tester.startGesture(Alignment.centerLeft.alongSize(textRect.size) + textRect.topLeft); + // Drag from centerLeft to centerRight to select the text. + await tester.pump(const Duration(seconds: 1)); + await gesture.moveTo(Alignment.centerRight.alongSize(textRect.size) + textRect.topLeft); + await tester.pump(); + + tooltipState.ensureTooltipVisible(); + await tester.pump(); + // Make sure the tooltip becomes visible. + expect(find.text(tooltipText), findsOneWidget); + assert(selectedText != null); + + final Rect tooltipTextRect = tester.getRect(find.text(tooltipText)); + // Now drag from centerLeft to centerRight to select the tooltip text. + await gesture.moveTo(Alignment.centerLeft.alongSize(tooltipTextRect.size) + tooltipTextRect.topLeft); + await tester.pump(); + await gesture.moveTo(Alignment.centerRight.alongSize(tooltipTextRect.size) + tooltipTextRect.topLeft); + await tester.pump(); + + expect(selectedText, isNot(contains('A'))); + }); } Future setWidgetForTooltipMode( From 95b3a3ed987ea214f5d06b8877d0a827d621edd4 Mon Sep 17 00:00:00 2001 From: LongCatIsLooong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Tue, 8 Aug 2023 10:57:04 -0700 Subject: [PATCH 12/58] [CP] Constrain _RenderScaledInlineWidget child size in computeDryLayout #131765 (#132096) CP request: https://github.com/flutter/flutter/issues/132095 --- packages/flutter/lib/src/widgets/widget_span.dart | 2 +- packages/flutter/test/widgets/text_test.dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/flutter/lib/src/widgets/widget_span.dart b/packages/flutter/lib/src/widgets/widget_span.dart index 776d4087ceff2..e15ace6655a42 100644 --- a/packages/flutter/lib/src/widgets/widget_span.dart +++ b/packages/flutter/lib/src/widgets/widget_span.dart @@ -362,7 +362,7 @@ class _RenderScaledInlineWidget extends RenderBox with RenderObjectWithChildMixi Size computeDryLayout(BoxConstraints constraints) { assert(!constraints.hasBoundedHeight); final Size unscaledSize = child?.computeDryLayout(BoxConstraints(maxWidth: constraints.maxWidth / scale)) ?? Size.zero; - return unscaledSize * scale; + return constraints.constrain(unscaledSize * scale); } @override diff --git a/packages/flutter/test/widgets/text_test.dart b/packages/flutter/test/widgets/text_test.dart index 0289a64d20b0c..b501a783f079d 100644 --- a/packages/flutter/test/widgets/text_test.dart +++ b/packages/flutter/test/widgets/text_test.dart @@ -273,8 +273,8 @@ void main() { textDirection: TextDirection.ltr, child: Center( child: SizedBox( - width: 100.3, - child: Text.rich(WidgetSpan(child: Row()), textScaleFactor: 0.3), + width: 502.5454545454545, + child: Text.rich(WidgetSpan(child: Row()), textScaleFactor: 0.95), ), ), ), From 7e07cd41cb78c1bb1199c458859c87ff818e1748 Mon Sep 17 00:00:00 2001 From: Casey Hillers Date: Tue, 8 Aug 2023 21:20:03 -0700 Subject: [PATCH 13/58] 3.13.0-0.4.pre Beta Cherrypicks (#132146) --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index ae36c7a225bbe..58f798c4fbd39 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -c1f977f72b68da673ec493ab5df0beacacc7a88b +73d89ca7c5a20cbddde69ab1a6dff8fe3ecbfd70 From 0ea0979cc20265920ea96d2e1613806184c60abb Mon Sep 17 00:00:00 2001 From: Victoria Ashworth <15619084+vashworth@users.noreply.github.com> Date: Mon, 14 Aug 2023 12:07:40 -0500 Subject: [PATCH 14/58] [CP] Check for iOS simulator runtime in flutter doctor for Xcode 15 (#132230) Original PR: #131795 --- packages/flutter_tools/lib/src/doctor.dart | 9 +- .../flutter_tools/lib/src/ios/simulators.dart | 107 +++++++++ .../flutter_tools/lib/src/macos/xcode.dart | 21 +- .../lib/src/macos/xcode_validator.dart | 66 +++++- .../general.shard/ios/simulators_test.dart | 158 ++++++++++++- .../test/general.shard/macos/xcode_test.dart | 56 +++++ .../macos/xcode_validator_test.dart | 211 +++++++++++++++++- packages/flutter_tools/test/src/context.dart | 3 + 8 files changed, 617 insertions(+), 14 deletions(-) diff --git a/packages/flutter_tools/lib/src/doctor.dart b/packages/flutter_tools/lib/src/doctor.dart index eb975fc17e047..255e938ef8caa 100644 --- a/packages/flutter_tools/lib/src/doctor.dart +++ b/packages/flutter_tools/lib/src/doctor.dart @@ -138,7 +138,14 @@ class _DefaultDoctorValidatorsProvider implements DoctorValidatorsProvider { if (androidWorkflow!.appliesToHostPlatform) GroupedValidator([androidValidator!, androidLicenseValidator!]), if (globals.iosWorkflow!.appliesToHostPlatform || macOSWorkflow.appliesToHostPlatform) - GroupedValidator([XcodeValidator(xcode: globals.xcode!, userMessages: userMessages), globals.cocoapodsValidator!]), + GroupedValidator([ + XcodeValidator( + xcode: globals.xcode!, + userMessages: userMessages, + iosSimulatorUtils: globals.iosSimulatorUtils!, + ), + globals.cocoapodsValidator!, + ]), if (webWorkflow.appliesToHostPlatform) ChromeValidator( chromiumLauncher: ChromiumLauncher( diff --git a/packages/flutter_tools/lib/src/ios/simulators.dart b/packages/flutter_tools/lib/src/ios/simulators.dart index 06211c67a07a2..49a6bad2b4627 100644 --- a/packages/flutter_tools/lib/src/ios/simulators.dart +++ b/packages/flutter_tools/lib/src/ios/simulators.dart @@ -15,6 +15,7 @@ import '../base/io.dart'; import '../base/logger.dart'; import '../base/process.dart'; import '../base/utils.dart'; +import '../base/version.dart'; import '../build_info.dart'; import '../convert.dart'; import '../devfs.dart'; @@ -91,6 +92,14 @@ class IOSSimulatorUtils { ); }).whereType().toList(); } + + Future> getAvailableIOSRuntimes() async { + if (!_xcode.isInstalledAndMeetsVersionCheck) { + return []; + } + + return _simControl.listAvailableIOSRuntimes(); + } } /// A wrapper around the `simctl` command line tool. @@ -293,6 +302,46 @@ class SimControl { _logger.printError('Unable to take screenshot of $deviceId:\n$exception'); } } + + /// Runs `simctl list runtimes available iOS --json` and returns all available iOS simulator runtimes. + Future> listAvailableIOSRuntimes() async { + final List runtimes = []; + final RunResult results = await _processUtils.run( + [ + ..._xcode.xcrunCommand(), + 'simctl', + 'list', + 'runtimes', + 'available', + 'iOS', + '--json', + ], + ); + + if (results.exitCode != 0) { + _logger.printError('Error executing simctl: ${results.exitCode}\n${results.stderr}'); + return runtimes; + } + + try { + final Object? decodeResult = (json.decode(results.stdout) as Map)['runtimes']; + if (decodeResult is List) { + for (final Object? runtimeData in decodeResult) { + if (runtimeData is Map) { + runtimes.add(IOSSimulatorRuntime.fromJson(runtimeData)); + } + } + } + + return runtimes; + } on FormatException { + // We failed to parse the simctl output, or it returned junk. + // One known message is "Install Started" isn't valid JSON but is + // returned sometimes. + _logger.printError('simctl returned non-JSON response: ${results.stdout}'); + return runtimes; + } + } } @@ -624,6 +673,64 @@ class IOSSimulator extends Device { } } +class IOSSimulatorRuntime { + IOSSimulatorRuntime._({ + this.bundlePath, + this.buildVersion, + this.platform, + this.runtimeRoot, + this.identifier, + this.version, + this.isInternal, + this.isAvailable, + this.name, + }); + + // Example: + // { + // "bundlePath" : "\/Library\/Developer\/CoreSimulator\/Volumes\/iOS_21A5277g\/Library\/Developer\/CoreSimulator\/Profiles\/Runtimes\/iOS 17.0.simruntime", + // "buildversion" : "21A5277g", + // "platform" : "iOS", + // "runtimeRoot" : "\/Library\/Developer\/CoreSimulator\/Volumes\/iOS_21A5277g\/Library\/Developer\/CoreSimulator\/Profiles\/Runtimes\/iOS 17.0.simruntime\/Contents\/Resources\/RuntimeRoot", + // "identifier" : "com.apple.CoreSimulator.SimRuntime.iOS-17-0", + // "version" : "17.0", + // "isInternal" : false, + // "isAvailable" : true, + // "name" : "iOS 17.0", + // "supportedDeviceTypes" : [ + // { + // "bundlePath" : "\/Applications\/Xcode.app\/Contents\/Developer\/Platforms\/iPhoneOS.platform\/Library\/Developer\/CoreSimulator\/Profiles\/DeviceTypes\/iPhone 8.simdevicetype", + // "name" : "iPhone 8", + // "identifier" : "com.apple.CoreSimulator.SimDeviceType.iPhone-8", + // "productFamily" : "iPhone" + // } + // ] + // }, + factory IOSSimulatorRuntime.fromJson(Map data) { + return IOSSimulatorRuntime._( + bundlePath: data['bundlePath']?.toString(), + buildVersion: data['buildversion']?.toString(), + platform: data['platform']?.toString(), + runtimeRoot: data['runtimeRoot']?.toString(), + identifier: data['identifier']?.toString(), + version: Version.parse(data['version']?.toString()), + isInternal: data['isInternal'] is bool? ? data['isInternal'] as bool? : null, + isAvailable: data['isAvailable'] is bool? ? data['isAvailable'] as bool? : null, + name: data['name']?.toString(), + ); + } + + final String? bundlePath; + final String? buildVersion; + final String? platform; + final String? runtimeRoot; + final String? identifier; + final Version? version; + final bool? isInternal; + final bool? isAvailable; + final String? name; +} + /// Launches the device log reader process on the host and parses the syslog. @visibleForTesting Future launchDeviceSystemLogTool(IOSSimulator device) async { diff --git a/packages/flutter_tools/lib/src/macos/xcode.dart b/packages/flutter_tools/lib/src/macos/xcode.dart index ff0b89e7e7f9a..4784013da8e70 100644 --- a/packages/flutter_tools/lib/src/macos/xcode.dart +++ b/packages/flutter_tools/lib/src/macos/xcode.dart @@ -48,7 +48,8 @@ class Xcode { _fileSystem = fileSystem, _xcodeProjectInterpreter = xcodeProjectInterpreter, _processUtils = - ProcessUtils(logger: logger, processManager: processManager); + ProcessUtils(logger: logger, processManager: processManager), + _logger = logger; /// Create an [Xcode] for testing. /// @@ -60,16 +61,18 @@ class Xcode { XcodeProjectInterpreter? xcodeProjectInterpreter, Platform? platform, FileSystem? fileSystem, + Logger? logger, }) { platform ??= FakePlatform( operatingSystem: 'macos', environment: {}, ); + logger ??= BufferLogger.test(); return Xcode( platform: platform, processManager: processManager, fileSystem: fileSystem ?? MemoryFileSystem.test(), - logger: BufferLogger.test(), + logger: logger, xcodeProjectInterpreter: xcodeProjectInterpreter ?? XcodeProjectInterpreter.test(processManager: processManager), ); } @@ -78,6 +81,7 @@ class Xcode { final ProcessUtils _processUtils; final FileSystem _fileSystem; final XcodeProjectInterpreter _xcodeProjectInterpreter; + final Logger _logger; bool get isInstalledAndMeetsVersionCheck => _platform.isMacOS && isInstalled && isRequiredVersionSatisfactory; @@ -198,6 +202,19 @@ class Xcode { final String appPath = _fileSystem.path.join(selectPath, 'Applications', 'Simulator.app'); return _fileSystem.directory(appPath).existsSync() ? appPath : null; } + + /// Gets the version number of the platform for the selected SDK. + Future sdkPlatformVersion(EnvironmentType environmentType) async { + final RunResult runResult = await _processUtils.run( + [...xcrunCommand(), '--sdk', getSDKNameForIOSEnvironmentType(environmentType), '--show-sdk-platform-version'], + ); + if (runResult.exitCode != 0) { + _logger.printError('Could not find SDK Platform Version: ${runResult.stderr}'); + return null; + } + final String versionString = runResult.stdout.trim(); + return Version.parse(versionString); + } } EnvironmentType? environmentTypeFromSdkroot(String sdkroot, FileSystem fileSystem) { diff --git a/packages/flutter_tools/lib/src/macos/xcode_validator.dart b/packages/flutter_tools/lib/src/macos/xcode_validator.dart index c45351c6728b3..1604876fb9c0c 100644 --- a/packages/flutter_tools/lib/src/macos/xcode_validator.dart +++ b/packages/flutter_tools/lib/src/macos/xcode_validator.dart @@ -3,18 +3,32 @@ // found in the LICENSE file. import '../base/user_messages.dart'; +import '../base/version.dart'; +import '../build_info.dart'; import '../doctor_validator.dart'; +import '../ios/simulators.dart'; import 'xcode.dart'; +String _iOSSimulatorMissing(String version) => ''' +iOS $version Simulator not installed; this may be necessary for iOS and macOS development. +To download and install the platform, open Xcode, select Xcode > Settings > Platforms, +and click the GET button for the required platform. + +For more information, please visit: + https://developer.apple.com/documentation/xcode/installing-additional-simulator-runtimes'''; + class XcodeValidator extends DoctorValidator { XcodeValidator({ required Xcode xcode, + required IOSSimulatorUtils iosSimulatorUtils, required UserMessages userMessages, - }) : _xcode = xcode, - _userMessages = userMessages, - super('Xcode - develop for iOS and macOS'); + }) : _xcode = xcode, + _iosSimulatorUtils = iosSimulatorUtils, + _userMessages = userMessages, + super('Xcode - develop for iOS and macOS'); final Xcode _xcode; + final IOSSimulatorUtils _iosSimulatorUtils; final UserMessages _userMessages; @override @@ -57,6 +71,11 @@ class XcodeValidator extends DoctorValidator { messages.add(ValidationMessage.error(_userMessages.xcodeMissingSimct)); } + final ValidationMessage? missingSimulatorMessage = await _validateSimulatorRuntimeInstalled(); + if (missingSimulatorMessage != null) { + xcodeStatus = ValidationType.partial; + messages.add(missingSimulatorMessage); + } } else { xcodeStatus = ValidationType.missing; if (xcodeSelectPath == null || xcodeSelectPath.isEmpty) { @@ -68,4 +87,45 @@ class XcodeValidator extends DoctorValidator { return ValidationResult(xcodeStatus, messages, statusInfo: xcodeVersionInfo); } + + /// Validate the Xcode-installed iOS simulator SDK has a corresponding iOS + /// simulator runtime installed. + /// + /// Starting with Xcode 15, the iOS simulator runtime is no longer downloaded + /// with Xcode and must be downloaded and installed separately. + /// iOS applications cannot be run without it. + Future _validateSimulatorRuntimeInstalled() async { + // Skip this validation if Xcode is not installed, Xcode is a version less + // than 15, simctl is not installed, or if the EULA is not signed. + if (!_xcode.isInstalled || + _xcode.currentVersion == null || + _xcode.currentVersion!.major < 15 || + !_xcode.isSimctlInstalled || + !_xcode.eulaSigned) { + return null; + } + + final Version? platformSDKVersion = await _xcode.sdkPlatformVersion(EnvironmentType.simulator); + if (platformSDKVersion == null) { + return const ValidationMessage.error('Unable to find the iPhone Simulator SDK.'); + } + + final List runtimes = await _iosSimulatorUtils.getAvailableIOSRuntimes(); + if (runtimes.isEmpty) { + return const ValidationMessage.error('Unable to get list of installed Simulator runtimes.'); + } + + // Verify there is a simulator runtime installed matching the + // iphonesimulator SDK major version. + try { + runtimes.firstWhere( + (IOSSimulatorRuntime runtime) => + runtime.version?.major == platformSDKVersion.major, + ); + } on StateError { + return ValidationMessage.hint(_iOSSimulatorMissing(platformSDKVersion.toString())); + } + + return null; + } } diff --git a/packages/flutter_tools/test/general.shard/ios/simulators_test.dart b/packages/flutter_tools/test/general.shard/ios/simulators_test.dart index e28578305ef69..f3830d0a09005 100644 --- a/packages/flutter_tools/test/general.shard/ios/simulators_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/simulators_test.dart @@ -8,6 +8,7 @@ import 'package:flutter_tools/src/base/io.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/platform.dart'; import 'package:flutter_tools/src/base/process.dart'; +import 'package:flutter_tools/src/base/version.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/devfs.dart'; import 'package:flutter_tools/src/device.dart'; @@ -771,14 +772,16 @@ Dec 20 17:04:32 md32-11-vm1 Another App[88374]: Ignore this text''' late FakeProcessManager fakeProcessManager; Xcode xcode; late SimControl simControl; + late BufferLogger logger; const String deviceId = 'smart-phone'; const String appId = 'flutterApp'; setUp(() { fakeProcessManager = FakeProcessManager.empty(); xcode = Xcode.test(processManager: FakeProcessManager.any()); + logger = BufferLogger.test(); simControl = SimControl( - logger: BufferLogger.test(), + logger: logger, processManager: fakeProcessManager, xcode: xcode, ); @@ -931,6 +934,159 @@ Dec 20 17:04:32 md32-11-vm1 Another App[88374]: Ignore this text''' expect(await iosSimulator.stopApp(null), isFalse); }); + + testWithoutContext('listAvailableIOSRuntimes succeeds', () async { + const String validRuntimesOutput = ''' +{ + "runtimes" : [ + { + "bundlePath" : "/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 15.4.simruntime", + "buildversion" : "19E240", + "platform" : "iOS", + "runtimeRoot" : "/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 15.4.simruntime/Contents/Resources/RuntimeRoot", + "identifier" : "com.apple.CoreSimulator.SimRuntime.iOS-15-4", + "version" : "15.4", + "isInternal" : false, + "isAvailable" : true, + "name" : "iOS 15.4", + "supportedDeviceTypes" : [ + { + "bundlePath" : "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/DeviceTypes/iPhone 6s.simdevicetype", + "name" : "iPhone 6s", + "identifier" : "com.apple.CoreSimulator.SimDeviceType.iPhone-6s", + "productFamily" : "iPhone" + }, + { + "bundlePath" : "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/DeviceTypes/iPhone 6s Plus.simdevicetype", + "name" : "iPhone 6s Plus", + "identifier" : "com.apple.CoreSimulator.SimDeviceType.iPhone-6s-Plus", + "productFamily" : "iPhone" + } + ] + }, + { + "bundlePath" : "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime", + "buildversion" : "20E247", + "platform" : "iOS", + "runtimeRoot" : "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot", + "identifier" : "com.apple.CoreSimulator.SimRuntime.iOS-16-4", + "version" : "16.4", + "isInternal" : false, + "isAvailable" : true, + "name" : "iOS 16.4", + "supportedDeviceTypes" : [ + { + "bundlePath" : "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/DeviceTypes/iPhone 8.simdevicetype", + "name" : "iPhone 8", + "identifier" : "com.apple.CoreSimulator.SimDeviceType.iPhone-8", + "productFamily" : "iPhone" + }, + { + "bundlePath" : "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/DeviceTypes/iPhone 8 Plus.simdevicetype", + "name" : "iPhone 8 Plus", + "identifier" : "com.apple.CoreSimulator.SimDeviceType.iPhone-8-Plus", + "productFamily" : "iPhone" + } + ] + }, + { + "bundlePath" : "/Library/Developer/CoreSimulator/Volumes/iOS_21A5268h/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 17.0.simruntime", + "buildversion" : "21A5268h", + "platform" : "iOS", + "runtimeRoot" : "/Library/Developer/CoreSimulator/Volumes/iOS_21A5268h/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 17.0.simruntime/Contents/Resources/RuntimeRoot", + "identifier" : "com.apple.CoreSimulator.SimRuntime.iOS-17-0", + "version" : "17.0", + "isInternal" : false, + "isAvailable" : true, + "name" : "iOS 17.0", + "supportedDeviceTypes" : [ + { + "bundlePath" : "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/DeviceTypes/iPhone 8.simdevicetype", + "name" : "iPhone 8", + "identifier" : "com.apple.CoreSimulator.SimDeviceType.iPhone-8", + "productFamily" : "iPhone" + }, + { + "bundlePath" : "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/DeviceTypes/iPhone 8 Plus.simdevicetype", + "name" : "iPhone 8 Plus", + "identifier" : "com.apple.CoreSimulator.SimDeviceType.iPhone-8-Plus", + "productFamily" : "iPhone" + } + ] + } + ] +} + +'''; + fakeProcessManager.addCommand(const FakeCommand( + command: [ + 'xcrun', + 'simctl', + 'list', + 'runtimes', + 'available', + 'iOS', + '--json', + ], + stdout: validRuntimesOutput, + )); + + final List runtimes = await simControl.listAvailableIOSRuntimes(); + + final IOSSimulatorRuntime runtime1 = runtimes[0]; + expect(runtime1.bundlePath, '/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 15.4.simruntime'); + expect(runtime1.buildVersion, '19E240'); + expect(runtime1.platform, 'iOS'); + expect(runtime1.runtimeRoot, '/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 15.4.simruntime/Contents/Resources/RuntimeRoot'); + expect(runtime1.identifier, 'com.apple.CoreSimulator.SimRuntime.iOS-15-4'); + expect(runtime1.version, Version(15, 4, null)); + expect(runtime1.isInternal, false); + expect(runtime1.isAvailable, true); + expect(runtime1.name, 'iOS 15.4'); + + final IOSSimulatorRuntime runtime2 = runtimes[1]; + expect(runtime2.bundlePath, '/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime'); + expect(runtime2.buildVersion, '20E247'); + expect(runtime2.platform, 'iOS'); + expect(runtime2.runtimeRoot, '/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot'); + expect(runtime2.identifier, 'com.apple.CoreSimulator.SimRuntime.iOS-16-4'); + expect(runtime2.version, Version(16, 4, null)); + expect(runtime2.isInternal, false); + expect(runtime2.isAvailable, true); + expect(runtime2.name, 'iOS 16.4'); + + final IOSSimulatorRuntime runtime3 = runtimes[2]; + expect(runtime3.bundlePath, '/Library/Developer/CoreSimulator/Volumes/iOS_21A5268h/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 17.0.simruntime'); + expect(runtime3.buildVersion, '21A5268h'); + expect(runtime3.platform, 'iOS'); + expect(runtime3.runtimeRoot, '/Library/Developer/CoreSimulator/Volumes/iOS_21A5268h/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 17.0.simruntime/Contents/Resources/RuntimeRoot'); + expect(runtime3.identifier, 'com.apple.CoreSimulator.SimRuntime.iOS-17-0'); + expect(runtime3.version, Version(17, 0, null)); + expect(runtime3.isInternal, false); + expect(runtime3.isAvailable, true); + expect(runtime3.name, 'iOS 17.0'); + }); + + testWithoutContext('listAvailableIOSRuntimes handles bad simctl output', () async { + fakeProcessManager.addCommand(const FakeCommand( + command: [ + 'xcrun', + 'simctl', + 'list', + 'runtimes', + 'available', + 'iOS', + '--json', + ], + stdout: 'Install Started', + )); + + final List runtimes = await simControl.listAvailableIOSRuntimes(); + + expect(runtimes, isEmpty); + expect(logger.errorText, contains('simctl returned non-JSON response:')); + expect(fakeProcessManager, hasNoRemainingExpectations); + }); }); group('startApp', () { diff --git a/packages/flutter_tools/test/general.shard/macos/xcode_test.dart b/packages/flutter_tools/test/general.shard/macos/xcode_test.dart index f2b2142090073..887712ad2e5c3 100644 --- a/packages/flutter_tools/test/general.shard/macos/xcode_test.dart +++ b/packages/flutter_tools/test/general.shard/macos/xcode_test.dart @@ -99,12 +99,15 @@ void main() { group('macOS', () { late Xcode xcode; + late BufferLogger logger; setUp(() { xcodeProjectInterpreter = FakeXcodeProjectInterpreter(); + logger = BufferLogger.test(); xcode = Xcode.test( processManager: fakeProcessManager, xcodeProjectInterpreter: xcodeProjectInterpreter, + logger: logger, ); }); @@ -277,6 +280,59 @@ void main() { expect(fakeProcessManager, hasNoRemainingExpectations); }); }); + + group('SDK Platform Version', () { + testWithoutContext('--show-sdk-platform-version iphonesimulator', () async { + fakeProcessManager.addCommand(const FakeCommand( + command: ['xcrun', '--sdk', 'iphonesimulator', '--show-sdk-platform-version'], + stdout: '16.4', + )); + + expect(await xcode.sdkPlatformVersion(EnvironmentType.simulator), Version(16, 4, null)); + expect(fakeProcessManager, hasNoRemainingExpectations); + }); + + testWithoutContext('--show-sdk-platform-version iphonesimulator with leading and trailing new line', () async { + fakeProcessManager.addCommand(const FakeCommand( + command: ['xcrun', '--sdk', 'iphonesimulator', '--show-sdk-platform-version'], + stdout: '\n16.4\n', + )); + + expect(await xcode.sdkPlatformVersion(EnvironmentType.simulator), Version(16, 4, null)); + expect(fakeProcessManager, hasNoRemainingExpectations); + }); + + testWithoutContext('--show-sdk-platform-version returns version followed by text', () async { + fakeProcessManager.addCommand(const FakeCommand( + command: ['xcrun', '--sdk', 'iphonesimulator', '--show-sdk-platform-version'], + stdout: '13.2 (a) 12344', + )); + + expect(await xcode.sdkPlatformVersion(EnvironmentType.simulator), Version(13, 2, null, text: '13.2 (a) 12344')); + expect(fakeProcessManager, hasNoRemainingExpectations); + }); + + testWithoutContext('--show-sdk-platform-version returns something unexpected', () async { + fakeProcessManager.addCommand(const FakeCommand( + command: ['xcrun', '--sdk', 'iphonesimulator', '--show-sdk-platform-version'], + stdout: 'bogus', + )); + + expect(await xcode.sdkPlatformVersion(EnvironmentType.simulator), null); + expect(fakeProcessManager, hasNoRemainingExpectations); + }); + + testWithoutContext('--show-sdk-platform-version fails', () async { + fakeProcessManager.addCommand(const FakeCommand( + command: ['xcrun', '--sdk', 'iphonesimulator', '--show-sdk-platform-version'], + exitCode: 1, + stderr: 'xcrun: error:', + )); + expect(await xcode.sdkPlatformVersion(EnvironmentType.simulator), null); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('Could not find SDK Platform Version')); + }); + }); }); }); diff --git a/packages/flutter_tools/test/general.shard/macos/xcode_validator_test.dart b/packages/flutter_tools/test/general.shard/macos/xcode_validator_test.dart index bb6668e7ace64..70a454a57d3ab 100644 --- a/packages/flutter_tools/test/general.shard/macos/xcode_validator_test.dart +++ b/packages/flutter_tools/test/general.shard/macos/xcode_validator_test.dart @@ -5,9 +5,11 @@ import 'package:flutter_tools/src/base/user_messages.dart'; import 'package:flutter_tools/src/base/version.dart'; import 'package:flutter_tools/src/doctor_validator.dart'; +import 'package:flutter_tools/src/ios/simulators.dart'; import 'package:flutter_tools/src/ios/xcodeproj.dart'; import 'package:flutter_tools/src/macos/xcode.dart'; import 'package:flutter_tools/src/macos/xcode_validator.dart'; +import 'package:test/fake.dart'; import '../../src/common.dart'; import '../../src/fake_process_manager.dart'; @@ -20,7 +22,11 @@ void main() { processManager: processManager, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager, version: null), ); - final XcodeValidator validator = XcodeValidator(xcode: xcode, userMessages: UserMessages()); + final XcodeValidator validator = XcodeValidator( + xcode: xcode, + userMessages: UserMessages(), + iosSimulatorUtils: FakeIOSSimulatorUtils(), + ); final ValidationResult result = await validator.validate(); expect(result.type, ValidationType.missing); expect(result.statusInfo, isNull); @@ -39,7 +45,11 @@ void main() { processManager: processManager, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager, version: null), ); - final XcodeValidator validator = XcodeValidator(xcode: xcode, userMessages: UserMessages()); + final XcodeValidator validator = XcodeValidator( + xcode: xcode, + userMessages: UserMessages(), + iosSimulatorUtils: FakeIOSSimulatorUtils(), + ); final ValidationResult result = await validator.validate(); expect(result.type, ValidationType.missing); expect(result.messages.last.type, ValidationMessageType.error); @@ -52,7 +62,11 @@ void main() { processManager: processManager, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager, version: Version(7, 0, 1)), ); - final XcodeValidator validator = XcodeValidator(xcode: xcode, userMessages: UserMessages()); + final XcodeValidator validator = XcodeValidator( + xcode: xcode, + userMessages: UserMessages(), + iosSimulatorUtils: FakeIOSSimulatorUtils(), + ); final ValidationResult result = await validator.validate(); expect(result.type, ValidationType.partial); expect(result.messages.last.type, ValidationMessageType.error); @@ -65,7 +79,11 @@ void main() { processManager: processManager, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager, version: Version(12, 4, null)), ); - final XcodeValidator validator = XcodeValidator(xcode: xcode, userMessages: UserMessages()); + final XcodeValidator validator = XcodeValidator( + xcode: xcode, + userMessages: UserMessages(), + iosSimulatorUtils: FakeIOSSimulatorUtils(), + ); final ValidationResult result = await validator.validate(); expect(result.type, ValidationType.partial); expect(result.messages.last.type, ValidationMessageType.hint); @@ -105,11 +123,16 @@ void main() { processManager: processManager, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), ); - final XcodeValidator validator = XcodeValidator(xcode: xcode, userMessages: UserMessages()); + final XcodeValidator validator = XcodeValidator( + xcode: xcode, + userMessages: UserMessages(), + iosSimulatorUtils: FakeIOSSimulatorUtils(), + ); final ValidationResult result = await validator.validate(); expect(result.type, ValidationType.partial); expect(result.messages.last.type, ValidationMessageType.error); expect(result.messages.last.message, contains('code end user license agreement not signed')); + expect(processManager, hasNoRemainingExpectations); }); testWithoutContext('Emits partial status when simctl is not installed', () async { @@ -143,11 +166,156 @@ void main() { processManager: processManager, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), ); - final XcodeValidator validator = XcodeValidator(xcode: xcode, userMessages: UserMessages()); + final XcodeValidator validator = XcodeValidator( + xcode: xcode, + userMessages: UserMessages(), + iosSimulatorUtils: FakeIOSSimulatorUtils(), + ); final ValidationResult result = await validator.validate(); expect(result.type, ValidationType.partial); expect(result.messages.last.type, ValidationMessageType.error); expect(result.messages.last.message, contains('Xcode requires additional components')); + expect(processManager, hasNoRemainingExpectations); + }); + + testWithoutContext('Emits partial status when unable to find simulator SDK', () async { + final ProcessManager processManager = FakeProcessManager.list([ + const FakeCommand( + command: ['/usr/bin/xcode-select', '--print-path'], + stdout: '/Library/Developer/CommandLineTools', + ), + const FakeCommand( + command: [ + 'which', + 'sysctl', + ], + ), + const FakeCommand( + command: [ + 'sysctl', + 'hw.optional.arm64', + ], + exitCode: 1, + ), + const FakeCommand( + command: ['xcrun', 'clang'], + ), + const FakeCommand( + command: ['xcrun', 'simctl', 'list', 'devices', 'booted'], + ), + const FakeCommand( + command: ['xcrun', '--sdk', 'iphonesimulator', '--show-sdk-platform-version'], + ), + ]); + final Xcode xcode = Xcode.test( + processManager: processManager, + xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + ); + final XcodeValidator validator = XcodeValidator( + xcode: xcode, + userMessages: UserMessages(), + iosSimulatorUtils: FakeIOSSimulatorUtils(), + ); + final ValidationResult result = await validator.validate(); + expect(result.type, ValidationType.partial); + expect(result.messages.last.type, ValidationMessageType.error); + expect(result.messages.last.message, contains('Unable to find the iPhone Simulator SDK')); + expect(processManager, hasNoRemainingExpectations); + }); + + testWithoutContext('Emits partial status when unable to get simulator runtimes', () async { + final ProcessManager processManager = FakeProcessManager.list([ + const FakeCommand( + command: ['/usr/bin/xcode-select', '--print-path'], + stdout: '/Library/Developer/CommandLineTools', + ), + const FakeCommand( + command: [ + 'which', + 'sysctl', + ], + ), + const FakeCommand( + command: [ + 'sysctl', + 'hw.optional.arm64', + ], + exitCode: 1, + ), + const FakeCommand( + command: ['xcrun', 'clang'], + ), + const FakeCommand( + command: ['xcrun', 'simctl', 'list', 'devices', 'booted'], + ), + const FakeCommand( + command: ['xcrun', '--sdk', 'iphonesimulator', '--show-sdk-platform-version'], + stdout: '17.0' + ), + ]); + final Xcode xcode = Xcode.test( + processManager: processManager, + xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + ); + final XcodeValidator validator = XcodeValidator( + xcode: xcode, + userMessages: UserMessages(), + iosSimulatorUtils: FakeIOSSimulatorUtils(), + ); + final ValidationResult result = await validator.validate(); + expect(result.type, ValidationType.partial); + expect(result.messages.last.type, ValidationMessageType.error); + expect(result.messages.last.message, contains('Unable to get list of installed Simulator runtimes')); + expect(processManager, hasNoRemainingExpectations); + }); + + testWithoutContext('Emits partial status with hint when simulator runtimes do not match SDK', () async { + final ProcessManager processManager = FakeProcessManager.list([ + const FakeCommand( + command: ['/usr/bin/xcode-select', '--print-path'], + stdout: '/Library/Developer/CommandLineTools', + ), + const FakeCommand( + command: [ + 'which', + 'sysctl', + ], + ), + const FakeCommand( + command: [ + 'sysctl', + 'hw.optional.arm64', + ], + exitCode: 1, + ), + const FakeCommand( + command: ['xcrun', 'clang'], + ), + const FakeCommand( + command: ['xcrun', 'simctl', 'list', 'devices', 'booted'], + ), + const FakeCommand( + command: ['xcrun', '--sdk', 'iphonesimulator', '--show-sdk-platform-version'], + stdout: '17.0' + ), + ]); + final Xcode xcode = Xcode.test( + processManager: processManager, + xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + ); + final FakeIOSSimulatorUtils simulatorUtils = FakeIOSSimulatorUtils(runtimes: [ + IOSSimulatorRuntime.fromJson({'version': '16.0'}), + ]); + final XcodeValidator validator = XcodeValidator( + xcode: xcode, + userMessages: UserMessages(), + iosSimulatorUtils: simulatorUtils, + ); + final ValidationResult result = await validator.validate(); + expect(result.type, ValidationType.partial); + expect(result.messages.last.type, ValidationMessageType.hint); + expect(result.messages.last.message, contains('iOS 17.0 Simulator not installed')); + expect(processManager, hasNoRemainingExpectations); }); testWithoutContext('Succeeds when all checks pass', () async { @@ -175,12 +343,23 @@ void main() { const FakeCommand( command: ['xcrun', 'simctl', 'list', 'devices', 'booted'], ), + const FakeCommand( + command: ['xcrun', '--sdk', 'iphonesimulator', '--show-sdk-platform-version'], + stdout: '17.0' + ), ]); final Xcode xcode = Xcode.test( processManager: processManager, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), ); - final XcodeValidator validator = XcodeValidator(xcode: xcode, userMessages: UserMessages()); + final FakeIOSSimulatorUtils simulatorUtils = FakeIOSSimulatorUtils(runtimes: [ + IOSSimulatorRuntime.fromJson({'version': '17.0'}), + ]); + final XcodeValidator validator = XcodeValidator( + xcode: xcode, + userMessages: UserMessages(), + iosSimulatorUtils: simulatorUtils, + ); final ValidationResult result = await validator.validate(); expect(result.type, ValidationType.success); expect(result.messages.length, 2); @@ -189,6 +368,24 @@ void main() { expect(firstMessage.message, 'Xcode at /Library/Developer/CommandLineTools'); expect(result.statusInfo, '1000.0.0'); expect(result.messages[1].message, 'Build 13C100'); + expect(processManager, hasNoRemainingExpectations); }); }); } + +class FakeIOSSimulatorUtils extends Fake implements IOSSimulatorUtils { + FakeIOSSimulatorUtils({ + this.runtimes, + }); + + List? runtimes; + + List get _runtimesList { + return runtimes ?? []; + } + + @override + Future> getAvailableIOSRuntimes() async { + return _runtimesList; + } +} diff --git a/packages/flutter_tools/test/src/context.dart b/packages/flutter_tools/test/src/context.dart index 0a70b2fd46799..7bea7532c3f5b 100644 --- a/packages/flutter_tools/test/src/context.dart +++ b/packages/flutter_tools/test/src/context.dart @@ -319,6 +319,9 @@ class NoopIOSSimulatorUtils implements IOSSimulatorUtils { @override Future> getAttachedDevices() async => []; + + @override + Future> getAvailableIOSRuntimes() async => []; } class FakeXcodeProjectInterpreter implements XcodeProjectInterpreter { From 54040e60411942430b82c807177d8e5f63c6abcc Mon Sep 17 00:00:00 2001 From: Victoria Ashworth <15619084+vashworth@users.noreply.github.com> Date: Mon, 14 Aug 2023 14:52:45 -0500 Subject: [PATCH 15/58] [CP] New tooling for iOS 17 physical devices (#132283) Two CP commits in a single PR. Original PRs: https://github.com/flutter/flutter/pull/131865 https://github.com/flutter/flutter/pull/132491 --- .ci.yaml | 20 + TESTOWNERS | 2 + ...ter_gallery_ios__start_up_xcode_debug.dart | 21 + ...integration_ui_ios_driver_xcode_debug.dart | 21 + .../lib/tasks/integration_tests.dart | 7 +- dev/devicelab/lib/tasks/perf_tests.dart | 40 +- packages/flutter_tools/bin/xcode_debug.js | 530 +++++ .../flutter_tools/lib/src/context_runner.dart | 2 + packages/flutter_tools/lib/src/device.dart | 6 +- .../lib/src/ios/core_devices.dart | 854 ++++++++ .../flutter_tools/lib/src/ios/devices.dart | 524 ++++- packages/flutter_tools/lib/src/ios/mac.dart | 2 + .../lib/src/ios/xcode_build_settings.dart | 9 + .../lib/src/ios/xcode_debug.dart | 485 ++++ .../flutter_tools/lib/src/macos/xcdevice.dart | 53 +- .../flutter_tools/lib/src/macos/xcode.dart | 65 + .../flutter_tools/lib/src/mdns_discovery.dart | 47 +- .../templates/template_manifest.json | 11 +- .../ios/custom_application_bundle/README.md | 5 + .../Runner.xcodeproj.tmpl/project.pbxproj | 297 +++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcschemes/Runner.xcscheme.tmpl | 82 + .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../test/general.shard/device_test.dart | 20 + .../general.shard/ios/core_devices_test.dart | 1949 +++++++++++++++++ .../test/general.shard/ios/devices_test.dart | 81 + .../ios/ios_device_install_test.dart | 105 + .../ios/ios_device_logger_test.dart | 650 +++++- .../ios/ios_device_project_test.dart | 10 + .../ios_device_start_nonprebuilt_test.dart | 433 +++- .../ios/ios_device_start_prebuilt_test.dart | 297 ++- .../general.shard/ios/xcode_debug_test.dart | 1136 ++++++++++ .../general.shard/ios/xcodeproj_test.dart | 70 + .../test/general.shard/macos/xcode_test.dart | 376 +++- .../general.shard/mdns_discovery_test.dart | 104 + 39 files changed, 8148 insertions(+), 212 deletions(-) create mode 100644 dev/devicelab/bin/tasks/flutter_gallery_ios__start_up_xcode_debug.dart create mode 100644 dev/devicelab/bin/tasks/integration_ui_ios_driver_xcode_debug.dart create mode 100644 packages/flutter_tools/bin/xcode_debug.js create mode 100644 packages/flutter_tools/lib/src/ios/core_devices.dart create mode 100644 packages/flutter_tools/lib/src/ios/xcode_debug.dart create mode 100644 packages/flutter_tools/templates/xcode/ios/custom_application_bundle/README.md create mode 100644 packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.pbxproj create mode 100644 packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/contents.xcworkspacedata create mode 100644 packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/xcshareddata/xcschemes/Runner.xcscheme.tmpl create mode 100644 packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/contents.xcworkspacedata create mode 100644 packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 packages/flutter_tools/test/general.shard/ios/core_devices_test.dart create mode 100644 packages/flutter_tools/test/general.shard/ios/xcode_debug_test.dart diff --git a/.ci.yaml b/.ci.yaml index cd162a085c68d..9116008c556af 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -3664,6 +3664,16 @@ targets: ["devicelab", "ios", "mac"] task_name: flutter_gallery_ios__start_up + - name: Mac_ios flutter_gallery_ios__start_up_xcode_debug + recipe: devicelab/devicelab_drone + presubmit: false + timeout: 60 + properties: + tags: > + ["devicelab", "ios", "mac"] + task_name: flutter_gallery_ios__start_up_xcode_debug + bringup: true + - name: Mac_ios flutter_view_ios__start_up recipe: devicelab/devicelab_drone presubmit: false @@ -3731,6 +3741,16 @@ targets: ["devicelab", "ios", "mac"] task_name: integration_ui_ios_driver + - name: Mac_ios integration_ui_ios_driver_xcode_debug + recipe: devicelab/devicelab_drone + presubmit: false + timeout: 60 + properties: + tags: > + ["devicelab", "ios", "mac"] + task_name: integration_ui_ios_driver_xcode_debug + bringup: true + - name: Mac_ios integration_ui_ios_frame_number recipe: devicelab/devicelab_drone presubmit: false diff --git a/TESTOWNERS b/TESTOWNERS index 49f579e8527eb..81fdb38686394 100644 --- a/TESTOWNERS +++ b/TESTOWNERS @@ -164,6 +164,7 @@ /dev/devicelab/bin/tasks/flutter_gallery__transition_perf_e2e_ios.dart @zanderso @flutter/engine /dev/devicelab/bin/tasks/flutter_gallery_ios__compile.dart @vashworth @flutter/engine /dev/devicelab/bin/tasks/flutter_gallery_ios__start_up.dart @vashworth @flutter/engine +/dev/devicelab/bin/tasks/flutter_gallery_ios__start_up_xcode_debug.dart @vashworth @flutter/engine /dev/devicelab/bin/tasks/flutter_gallery_ios_sksl_warmup__transition_perf.dart @zanderso @flutter/engine /dev/devicelab/bin/tasks/flutter_view_ios__start_up.dart @zanderso @flutter/engine /dev/devicelab/bin/tasks/fullscreen_textfield_perf_ios__e2e_summary.dart @cyanglaz @flutter/engine @@ -174,6 +175,7 @@ /dev/devicelab/bin/tasks/imagefiltered_transform_animation_perf_ios__timeline_summary.dart @cyanglaz @flutter/engine /dev/devicelab/bin/tasks/integration_test_test_ios.dart @cyanglaz @flutter/engine /dev/devicelab/bin/tasks/integration_ui_ios_driver.dart @cyanglaz @flutter/tool +/dev/devicelab/bin/tasks/integration_ui_ios_driver_xcode_debug.dart @vashworth @flutter/tool /dev/devicelab/bin/tasks/integration_ui_ios_frame_number.dart @iskakaushik @flutter/engine /dev/devicelab/bin/tasks/integration_ui_ios_keyboard_resize.dart @cyanglaz @flutter/engine /dev/devicelab/bin/tasks/integration_ui_ios_screenshot.dart @cyanglaz @flutter/tool diff --git a/dev/devicelab/bin/tasks/flutter_gallery_ios__start_up_xcode_debug.dart b/dev/devicelab/bin/tasks/flutter_gallery_ios__start_up_xcode_debug.dart new file mode 100644 index 0000000000000..a17c45d2d8678 --- /dev/null +++ b/dev/devicelab/bin/tasks/flutter_gallery_ios__start_up_xcode_debug.dart @@ -0,0 +1,21 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_devicelab/framework/devices.dart'; +import 'package:flutter_devicelab/framework/framework.dart'; +import 'package:flutter_devicelab/tasks/perf_tests.dart'; + +Future main() async { + // TODO(vashworth): Remove after Xcode 15 and iOS 17 are in CI (https://github.com/flutter/flutter/issues/132128) + // XcodeDebug workflow is used for CoreDevices (iOS 17+ and Xcode 15+). Use + // FORCE_XCODE_DEBUG environment variable to force the use of XcodeDebug + // workflow in CI to test from older versions since devicelab has not yet been + // updated to iOS 17 and Xcode 15. + deviceOperatingSystem = DeviceOperatingSystem.ios; + await task(createFlutterGalleryStartupTest( + runEnvironment: { + 'FORCE_XCODE_DEBUG': 'true', + }, + )); +} diff --git a/dev/devicelab/bin/tasks/integration_ui_ios_driver_xcode_debug.dart b/dev/devicelab/bin/tasks/integration_ui_ios_driver_xcode_debug.dart new file mode 100644 index 0000000000000..f51b231d2953f --- /dev/null +++ b/dev/devicelab/bin/tasks/integration_ui_ios_driver_xcode_debug.dart @@ -0,0 +1,21 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_devicelab/framework/devices.dart'; +import 'package:flutter_devicelab/framework/framework.dart'; +import 'package:flutter_devicelab/tasks/integration_tests.dart'; + +Future main() async { + // TODO(vashworth): Remove after Xcode 15 and iOS 17 are in CI (https://github.com/flutter/flutter/issues/132128) + // XcodeDebug workflow is used for CoreDevices (iOS 17+ and Xcode 15+). Use + // FORCE_XCODE_DEBUG environment variable to force the use of XcodeDebug + // workflow in CI to test from older versions since devicelab has not yet been + // updated to iOS 17 and Xcode 15. + deviceOperatingSystem = DeviceOperatingSystem.ios; + await task(createEndToEndDriverTest( + environment: { + 'FORCE_XCODE_DEBUG': 'true', + }, + )); +} diff --git a/dev/devicelab/lib/tasks/integration_tests.dart b/dev/devicelab/lib/tasks/integration_tests.dart index 0172d576fe417..c06e717874d17 100644 --- a/dev/devicelab/lib/tasks/integration_tests.dart +++ b/dev/devicelab/lib/tasks/integration_tests.dart @@ -106,10 +106,11 @@ TaskFunction createEndToEndFrameNumberTest() { ).call; } -TaskFunction createEndToEndDriverTest() { +TaskFunction createEndToEndDriverTest({Map? environment}) { return DriverTest( '${flutterDirectory.path}/dev/integration_tests/ui', 'lib/driver.dart', + environment: environment, ).call; } @@ -173,6 +174,7 @@ class DriverTest { this.testTarget, { this.extraOptions = const [], this.deviceIdOverride, + this.environment, } ); @@ -180,6 +182,7 @@ class DriverTest { final String testTarget; final List extraOptions; final String? deviceIdOverride; + final Map? environment; Future call() { return inDirectory(testDirectory, () async { @@ -202,7 +205,7 @@ class DriverTest { deviceId, ...extraOptions, ]; - await flutter('drive', options: options); + await flutter('drive', options: options, environment: environment); return TaskResult.success(null); }); diff --git a/dev/devicelab/lib/tasks/perf_tests.dart b/dev/devicelab/lib/tasks/perf_tests.dart index b35bb41a1342d..193e4e57fba3f 100644 --- a/dev/devicelab/lib/tasks/perf_tests.dart +++ b/dev/devicelab/lib/tasks/perf_tests.dart @@ -232,10 +232,11 @@ TaskFunction createOpenPayScrollPerfTest({bool measureCpuGpu = true}) { ).run; } -TaskFunction createFlutterGalleryStartupTest({String target = 'lib/main.dart'}) { +TaskFunction createFlutterGalleryStartupTest({String target = 'lib/main.dart', Map? runEnvironment}) { return StartupTest( '${flutterDirectory.path}/dev/integration_tests/flutter_gallery', target: target, + runEnvironment: runEnvironment, ).run; } @@ -693,11 +694,17 @@ Map _average(List> results, int iterations /// Measure application startup performance. class StartupTest { - const StartupTest(this.testDirectory, { this.reportMetrics = true, this.target = 'lib/main.dart' }); + const StartupTest( + this.testDirectory, { + this.reportMetrics = true, + this.target = 'lib/main.dart', + this.runEnvironment, + }); final String testDirectory; final bool reportMetrics; final String target; + final Map? runEnvironment; Future run() async { return inDirectory(testDirectory, () async { @@ -771,18 +778,23 @@ class StartupTest { const int maxFailures = 3; int currentFailures = 0; for (int i = 0; i < iterations; i += 1) { - final int result = await flutter('run', options: [ - '--no-android-gradle-daemon', - '--no-publish-port', - '--verbose', - '--profile', - '--trace-startup', - '--target=$target', - '-d', - device.deviceId, - if (applicationBinaryPath != null) - '--use-application-binary=$applicationBinaryPath', - ], canFail: true); + final int result = await flutter( + 'run', + options: [ + '--no-android-gradle-daemon', + '--no-publish-port', + '--verbose', + '--profile', + '--trace-startup', + '--target=$target', + '-d', + device.deviceId, + if (applicationBinaryPath != null) + '--use-application-binary=$applicationBinaryPath', + ], + environment: runEnvironment, + canFail: true, + ); if (result == 0) { final Map data = json.decode( file('${_testOutputDirectory(testDirectory)}/start_up_info.json').readAsStringSync(), diff --git a/packages/flutter_tools/bin/xcode_debug.js b/packages/flutter_tools/bin/xcode_debug.js new file mode 100644 index 0000000000000..25f16a27298aa --- /dev/null +++ b/packages/flutter_tools/bin/xcode_debug.js @@ -0,0 +1,530 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * @fileoverview OSA Script to interact with Xcode. Functionality includes + * checking if a given project is open in Xcode, starting a debug session for + * a given project, and stopping a debug session for a given project. + */ + +'use strict'; + +/** + * OSA Script `run` handler that is called when the script is run. When ran + * with `osascript`, arguments are passed from the command line to the direct + * parameter of the `run` handler as a list of strings. + * + * @param {?Array=} args_array + * @returns {!RunJsonResponse} The validated command. + */ +function run(args_array = []) { + let args; + try { + args = new CommandArguments(args_array); + } catch (e) { + return new RunJsonResponse(false, `Failed to parse arguments: ${e}`).stringify(); + } + + const xcodeResult = getXcode(args); + if (xcodeResult.error != null) { + return new RunJsonResponse(false, xcodeResult.error).stringify(); + } + const xcode = xcodeResult.result; + + if (args.command === 'check-workspace-opened') { + const result = getWorkspaceDocument(xcode, args); + return new RunJsonResponse(result.error == null, result.error).stringify(); + } else if (args.command === 'debug') { + const result = debugApp(xcode, args); + return new RunJsonResponse(result.error == null, result.error, result.result).stringify(); + } else if (args.command === 'stop') { + const result = stopApp(xcode, args); + return new RunJsonResponse(result.error == null, result.error).stringify(); + } else { + return new RunJsonResponse(false, 'Unknown command').stringify(); + } +} + +/** + * Parsed and validated arguments passed from the command line. + */ +class CommandArguments { + /** + * + * @param {!Array} args List of arguments passed from the command line. + */ + constructor(args) { + this.command = this.validatedCommand(args[0]); + + const parsedArguments = this.parseArguments(args); + + this.xcodePath = this.validatedStringArgument('--xcode-path', parsedArguments['--xcode-path']); + this.projectPath = this.validatedStringArgument('--project-path', parsedArguments['--project-path']); + this.workspacePath = this.validatedStringArgument('--workspace-path', parsedArguments['--workspace-path']); + this.targetDestinationId = this.validatedStringArgument('--device-id', parsedArguments['--device-id']); + this.targetSchemeName = this.validatedStringArgument('--scheme', parsedArguments['--scheme']); + this.skipBuilding = this.validatedBoolArgument('--skip-building', parsedArguments['--skip-building']); + this.launchArguments = this.validatedJsonArgument('--launch-args', parsedArguments['--launch-args']); + this.closeWindowOnStop = this.validatedBoolArgument('--close-window', parsedArguments['--close-window']); + this.promptToSaveBeforeClose = this.validatedBoolArgument('--prompt-to-save', parsedArguments['--prompt-to-save']); + this.verbose = this.validatedBoolArgument('--verbose', parsedArguments['--verbose']); + + if (this.verbose === true) { + console.log(JSON.stringify(this)); + } + } + + /** + * Validates the command is available. + * + * @param {?string} command + * @returns {!string} The validated command. + * @throws Will throw an error if command is not recognized. + */ + validatedCommand(command) { + const allowedCommands = ['check-workspace-opened', 'debug', 'stop']; + if (allowedCommands.includes(command) === false) { + throw `Unrecognized Command: ${command}`; + } + + return command; + } + + /** + * Validates the flag is allowed for the current command. + * + * @param {!string} flag + * @param {?string} value + * @returns {!bool} + * @throws Will throw an error if the flag is not allowed for the current + * command and the value is not null, undefined, or empty. + */ + isArgumentAllowed(flag, value) { + const allowedArguments = { + 'common': { + '--xcode-path': true, + '--project-path': true, + '--workspace-path': true, + '--verbose': true, + }, + 'check-workspace-opened': {}, + 'debug': { + '--device-id': true, + '--scheme': true, + '--skip-building': true, + '--launch-args': true, + }, + 'stop': { + '--close-window': true, + '--prompt-to-save': true, + }, + } + + const isAllowed = allowedArguments['common'][flag] === true || allowedArguments[this.command][flag] === true; + if (isAllowed === false && (value != null && value !== '')) { + throw `The flag ${flag} is not allowed for the command ${this.command}.`; + } + return isAllowed; + } + + /** + * Parses the command line arguments into an object. + * + * @param {!Array} args List of arguments passed from the command line. + * @returns {!Object.} Object mapping flag to value. + * @throws Will throw an error if flag does not begin with '--'. + */ + parseArguments(args) { + const valuesPerFlag = {}; + for (let index = 1; index < args.length; index++) { + const entry = args[index]; + let flag; + let value; + const splitIndex = entry.indexOf('='); + if (splitIndex === -1) { + flag = entry; + value = args[index + 1]; + + // If the flag is allowed for the command, and the next value in the + // array is null/undefined or also a flag, treat the flag like a boolean + // flag and set the value to 'true'. + if (this.isArgumentAllowed(flag) && (value == null || value.startsWith('--'))) { + value = 'true'; + } else { + index++; + } + } else { + flag = entry.substring(0, splitIndex); + value = entry.substring(splitIndex + 1, entry.length + 1); + } + if (flag.startsWith('--') === false) { + throw `Unrecognized Flag: ${flag}`; + } + + valuesPerFlag[flag] = value; + } + return valuesPerFlag; + } + + + /** + * Validates the flag is allowed and `value` is valid. If the flag is not + * allowed for the current command, return `null`. + * + * @param {!string} flag + * @param {?string} value + * @returns {!string} + * @throws Will throw an error if the flag is allowed and `value` is null, + * undefined, or empty. + */ + validatedStringArgument(flag, value) { + if (this.isArgumentAllowed(flag, value) === false) { + return null; + } + if (value == null || value === '') { + throw `Missing value for ${flag}`; + } + return value; + } + + /** + * Validates the flag is allowed, validates `value` is valid, and converts + * `value` to a boolean. A `value` of null, undefined, or empty, it will + * return true. If the flag is not allowed for the current command, will + * return `null`. + * + * @param {?string} value + * @returns {?boolean} + * @throws Will throw an error if the flag is allowed and `value` is not + * null, undefined, empty, 'true', or 'false'. + */ + validatedBoolArgument(flag, value) { + if (this.isArgumentAllowed(flag, value) === false) { + return null; + } + if (value == null || value === '') { + return false; + } + if (value !== 'true' && value !== 'false') { + throw `Invalid value for ${flag}`; + } + return value === 'true'; + } + + /** + * Validates the flag is allowed, `value` is valid, and parses `value` as JSON. + * If the flag is not allowed for the current command, will return `null`. + * + * @param {!string} flag + * @param {?string} value + * @returns {!Object} + * @throws Will throw an error if the flag is allowed and the value is + * null, undefined, or empty. Will also throw an error if parsing fails. + */ + validatedJsonArgument(flag, value) { + if (this.isArgumentAllowed(flag, value) === false) { + return null; + } + if (value == null || value === '') { + throw `Missing value for ${flag}`; + } + try { + return JSON.parse(value); + } catch (e) { + throw `Error parsing ${flag}: ${e}`; + } + } +} + +/** + * Response to return in `run` function. + */ +class RunJsonResponse { + /** + * + * @param {!bool} success Whether the command was successful. + * @param {?string=} errorMessage Defaults to null. + * @param {?DebugResult=} debugResult Curated results from Xcode's debug + * function. Defaults to null. + */ + constructor(success, errorMessage = null, debugResult = null) { + this.status = success; + this.errorMessage = errorMessage; + this.debugResult = debugResult; + } + + /** + * Converts this object to a JSON string. + * + * @returns {!string} + * @throws Throws an error if conversion fails. + */ + stringify() { + return JSON.stringify(this); + } +} + +/** + * Utility class to return a result along with a potential error. + */ +class FunctionResult { + /** + * + * @param {?Object} result + * @param {?string=} error Defaults to null. + */ + constructor(result, error = null) { + this.result = result; + this.error = error; + } +} + +/** + * Curated results from Xcode's debug function. Mirrors parts of + * `scheme action result` from Xcode's Script Editor dictionary. + */ +class DebugResult { + /** + * + * @param {!Object} result + */ + constructor(result) { + this.completed = result.completed(); + this.status = result.status(); + this.errorMessage = result.errorMessage(); + } +} + +/** + * Get the Xcode application from the given path. Since macs can have multiple + * Xcode version, we use the path to target the specific Xcode application. + * If the Xcode app is not running, return null with an error. + * + * @param {!CommandArguments} args + * @returns {!FunctionResult} Return either an `Application` (Mac Scripting class) + * or null as the `result`. + */ +function getXcode(args) { + try { + const xcode = Application(args.xcodePath); + const isXcodeRunning = xcode.running(); + + if (isXcodeRunning === false) { + return new FunctionResult(null, 'Xcode is not running'); + } + + return new FunctionResult(xcode); + } catch (e) { + return new FunctionResult(null, `Failed to get Xcode application: ${e}`); + } +} + +/** + * After setting the active run destination to the targeted device, uses Xcode + * debug function from Mac Scripting for Xcode to install the app on the device + * and start a debugging session using the 'run' or 'run without building' scheme + * action (depending on `args.skipBuilding`). Waits for the debugging session + * to start running. + * + * @param {!Application} xcode An `Application` (Mac Scripting class) for Xcode. + * @param {!CommandArguments} args + * @returns {!FunctionResult} Return either a `DebugResult` or null as the `result`. + */ +function debugApp(xcode, args) { + const workspaceResult = waitForWorkspaceToLoad(xcode, args); + if (workspaceResult.error != null) { + return new FunctionResult(null, workspaceResult.error); + } + const targetWorkspace = workspaceResult.result; + + const destinationResult = getTargetDestination( + targetWorkspace, + args.targetDestinationId, + args.verbose, + ); + if (destinationResult.error != null) { + return new FunctionResult(null, destinationResult.error) + } + + try { + // Documentation from the Xcode Script Editor dictionary indicates that the + // `debug` function has a parameter called `runDestinationSpecifier` which + // is used to specify which device to debug the app on. It also states that + // it should be the same as the xcodebuild -destination specifier. It also + // states that if not specified, the `activeRunDestination` is used instead. + // + // Experimentation has shown that the `runDestinationSpecifier` does not work. + // It will always use the `activeRunDestination`. To mitigate this, we set + // the `activeRunDestination` to the targeted device prior to starting the debug. + targetWorkspace.activeRunDestination = destinationResult.result; + + const actionResult = targetWorkspace.debug({ + scheme: args.targetSchemeName, + skipBuilding: args.skipBuilding, + commandLineArguments: args.launchArguments, + }); + + // Wait until scheme action has started up to a max of 10 minutes. + // This does not wait for app to install, launch, or start debug session. + // Potential statuses include: not yet started/โ€Œrunning/โ€Œcancelled/โ€Œfailed/โ€Œerror occurred/โ€Œsucceeded. + const checkFrequencyInSeconds = 0.5; + const maxWaitInSeconds = 10 * 60; // 10 minutes + const iterations = maxWaitInSeconds * (1 / checkFrequencyInSeconds); + const verboseLogInterval = 10 * (1 / checkFrequencyInSeconds); + for (let i = 0; i < iterations; i++) { + if (actionResult.status() !== 'not yet started') { + break; + } + if (args.verbose === true && i % verboseLogInterval === 0) { + console.log(`Action result status: ${actionResult.status()}`); + } + delay(checkFrequencyInSeconds); + } + + return new FunctionResult(new DebugResult(actionResult)); + } catch (e) { + return new FunctionResult(null, `Failed to start debugging session: ${e}`); + } +} + +/** + * Iterates through available run destinations looking for one with a matching + * `deviceId`. If device is not found, return null with an error. + * + * @param {!WorkspaceDocument} targetWorkspace A `WorkspaceDocument` (Xcode Mac + * Scripting class). + * @param {!string} deviceId + * @param {?bool=} verbose Defaults to false. + * @returns {!FunctionResult} Return either a `RunDestination` (Xcode Mac + * Scripting class) or null as the `result`. + */ +function getTargetDestination(targetWorkspace, deviceId, verbose = false) { + try { + for (let destination of targetWorkspace.runDestinations()) { + const device = destination.device(); + if (verbose === true && device != null) { + console.log(`Device: ${device.name()} (${device.deviceIdentifier()})`); + } + if (device != null && device.deviceIdentifier() === deviceId) { + return new FunctionResult(destination); + } + } + return new FunctionResult( + null, + 'Unable to find target device. Ensure that the device is paired, ' + + 'unlocked, connected, and has an iOS version at least as high as the ' + + 'Minimum Deployment.', + ); + } catch (e) { + return new FunctionResult(null, `Failed to get target destination: ${e}`); + } +} + +/** + * Waits for the workspace to load. If the workspace is not loaded or in the + * process of opening, it will wait up to 10 minutes. + * + * @param {!Application} xcode An `Application` (Mac Scripting class) for Xcode. + * @param {!CommandArguments} args + * @returns {!FunctionResult} Return either a `WorkspaceDocument` (Xcode Mac + * Scripting class) or null as the `result`. + */ +function waitForWorkspaceToLoad(xcode, args) { + try { + const checkFrequencyInSeconds = 0.5; + const maxWaitInSeconds = 10 * 60; // 10 minutes + const verboseLogInterval = 10 * (1 / checkFrequencyInSeconds); + const iterations = maxWaitInSeconds * (1 / checkFrequencyInSeconds); + for (let i = 0; i < iterations; i++) { + // Every 10 seconds, print the list of workspaces if verbose + const verbose = args.verbose && i % verboseLogInterval === 0; + + const workspaceResult = getWorkspaceDocument(xcode, args, verbose); + if (workspaceResult.error == null) { + const document = workspaceResult.result; + if (document.loaded() === true) { + return new FunctionResult(document, null); + } + } else if (verbose === true) { + console.log(workspaceResult.error); + } + delay(checkFrequencyInSeconds); + } + return new FunctionResult(null, 'Timed out waiting for workspace to load'); + } catch (e) { + return new FunctionResult(null, `Failed to wait for workspace to load: ${e}`); + } +} + +/** + * Gets workspace opened in Xcode matching the projectPath or workspacePath + * from the command line arguments. If workspace is not found, return null with + * an error. + * + * @param {!Application} xcode An `Application` (Mac Scripting class) for Xcode. + * @param {!CommandArguments} args + * @param {?bool=} verbose Defaults to false. + * @returns {!FunctionResult} Return either a `WorkspaceDocument` (Xcode Mac + * Scripting class) or null as the `result`. + */ +function getWorkspaceDocument(xcode, args, verbose = false) { + const privatePrefix = '/private'; + + try { + const documents = xcode.workspaceDocuments(); + for (let document of documents) { + const filePath = document.file().toString(); + if (verbose === true) { + console.log(`Workspace: ${filePath}`); + } + if (filePath === args.projectPath || filePath === args.workspacePath) { + return new FunctionResult(document); + } + // Sometimes when the project is in a temporary directory, it'll be + // prefixed with `/private` but the args will not. Remove the + // prefix before matching. + if (filePath.startsWith(privatePrefix) === true) { + const filePathWithoutPrefix = filePath.slice(privatePrefix.length); + if (filePathWithoutPrefix === args.projectPath || filePathWithoutPrefix === args.workspacePath) { + return new FunctionResult(document); + } + } + } + } catch (e) { + return new FunctionResult(null, `Failed to get workspace: ${e}`); + } + return new FunctionResult(null, `Failed to get workspace.`); +} + +/** + * Stops all debug sessions in the target workspace. + * + * @param {!Application} xcode An `Application` (Mac Scripting class) for Xcode. + * @param {!CommandArguments} args + * @returns {!FunctionResult} Always returns null as the `result`. + */ +function stopApp(xcode, args) { + const workspaceResult = getWorkspaceDocument(xcode, args); + if (workspaceResult.error != null) { + return new FunctionResult(null, workspaceResult.error); + } + const targetDocument = workspaceResult.result; + + try { + targetDocument.stop(); + + if (args.closeWindowOnStop === true) { + // Wait a couple seconds before closing Xcode, otherwise it'll prompt the + // user to stop the app. + delay(2); + + targetDocument.close({ + saving: args.promptToSaveBeforeClose === true ? 'ask' : 'no', + }); + } + } catch (e) { + return new FunctionResult(null, `Failed to stop app: ${e}`); + } + return new FunctionResult(null, null); +} diff --git a/packages/flutter_tools/lib/src/context_runner.dart b/packages/flutter_tools/lib/src/context_runner.dart index 08cefe5169c7d..a5a4f92c25048 100644 --- a/packages/flutter_tools/lib/src/context_runner.dart +++ b/packages/flutter_tools/lib/src/context_runner.dart @@ -359,6 +359,7 @@ Future runInContext( platform: globals.platform, fileSystem: globals.fs, xcodeProjectInterpreter: globals.xcodeProjectInterpreter!, + userMessages: globals.userMessages, ), XCDevice: () => XCDevice( processManager: globals.processManager, @@ -375,6 +376,7 @@ Future runInContext( processManager: globals.processManager, dyLdLibEntry: globals.cache.dyLdLibEntry, ), + fileSystem: globals.fs, ), XcodeProjectInterpreter: () => XcodeProjectInterpreter( logger: globals.logger, diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart index a919109367eab..091bac0f48d0f 100644 --- a/packages/flutter_tools/lib/src/device.dart +++ b/packages/flutter_tools/lib/src/device.dart @@ -1156,6 +1156,7 @@ class DebuggingOptions { Map platformArgs, { bool ipv6 = false, DeviceConnectionInterface interfaceType = DeviceConnectionInterface.attached, + bool isCoreDevice = false, }) { final String dartVmFlags = computeDartVmFlags(this); return [ @@ -1169,7 +1170,10 @@ class DebuggingOptions { if (environmentType == EnvironmentType.simulator && dartVmFlags.isNotEmpty) '--dart-flags=$dartVmFlags', if (useTestFonts) '--use-test-fonts', - if (debuggingEnabled) ...[ + // Core Devices (iOS 17 devices) are debugged through Xcode so don't + // include these flags, which are used to check if the app was launched + // via Flutter CLI and `ios-deploy`. + if (debuggingEnabled && !isCoreDevice) ...[ '--enable-checked-mode', '--verify-entry-points', ], diff --git a/packages/flutter_tools/lib/src/ios/core_devices.dart b/packages/flutter_tools/lib/src/ios/core_devices.dart new file mode 100644 index 0000000000000..aa39adbf66cb3 --- /dev/null +++ b/packages/flutter_tools/lib/src/ios/core_devices.dart @@ -0,0 +1,854 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:meta/meta.dart'; +import 'package:process/process.dart'; + +import '../base/file_system.dart'; +import '../base/io.dart'; +import '../base/logger.dart'; +import '../base/process.dart'; +import '../convert.dart'; +import '../device.dart'; +import '../macos/xcode.dart'; + +/// A wrapper around the `devicectl` command line tool. +/// +/// CoreDevice is a device connectivity stack introduced in Xcode 15. Devices +/// with iOS 17 or greater are CoreDevices. +/// +/// `devicectl` (CoreDevice Device Control) is an Xcode CLI tool used for +/// interacting with CoreDevices. +class IOSCoreDeviceControl { + IOSCoreDeviceControl({ + required Logger logger, + required ProcessManager processManager, + required Xcode xcode, + required FileSystem fileSystem, + }) : _logger = logger, + _processUtils = ProcessUtils(logger: logger, processManager: processManager), + _xcode = xcode, + _fileSystem = fileSystem; + + final Logger _logger; + final ProcessUtils _processUtils; + final Xcode _xcode; + final FileSystem _fileSystem; + + /// When the `--timeout` flag is used with `devicectl`, it must be at + /// least 5 seconds. If lower than 5 seconds, `devicectl` will error and not + /// run the command. + static const int _minimumTimeoutInSeconds = 5; + + /// Executes `devicectl` command to get list of devices. The command will + /// likely complete before [timeout] is reached. If [timeout] is reached, + /// the command will be stopped as a failure. + Future> _listCoreDevices({ + Duration timeout = const Duration(seconds: _minimumTimeoutInSeconds), + }) async { + if (!_xcode.isDevicectlInstalled) { + _logger.printError('devicectl is not installed.'); + return []; + } + + // Default to minimum timeout if needed to prevent error. + Duration validTimeout = timeout; + if (timeout.inSeconds < _minimumTimeoutInSeconds) { + _logger.printError( + 'Timeout of ${timeout.inSeconds} seconds is below the minimum timeout value ' + 'for devicectl. Changing the timeout to the minimum value of $_minimumTimeoutInSeconds.'); + validTimeout = const Duration(seconds: _minimumTimeoutInSeconds); + } + + final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('core_devices.'); + final File output = tempDirectory.childFile('core_device_list.json'); + output.createSync(); + + final List command = [ + ..._xcode.xcrunCommand(), + 'devicectl', + 'list', + 'devices', + '--timeout', + validTimeout.inSeconds.toString(), + '--json-output', + output.path, + ]; + + try { + await _processUtils.run(command, throwOnError: true); + + final String stringOutput = output.readAsStringSync(); + _logger.printTrace(stringOutput); + + try { + final Object? decodeResult = (json.decode(stringOutput) as Map)['result']; + if (decodeResult is Map) { + final Object? decodeDevices = decodeResult['devices']; + if (decodeDevices is List) { + return decodeDevices; + } + } + _logger.printError('devicectl returned unexpected JSON response: $stringOutput'); + return []; + } on FormatException { + // We failed to parse the devicectl output, or it returned junk. + _logger.printError('devicectl returned non-JSON response: $stringOutput'); + return []; + } + } on ProcessException catch (err) { + _logger.printError('Error executing devicectl: $err'); + return []; + } finally { + tempDirectory.deleteSync(recursive: true); + } + } + + Future> getCoreDevices({ + Duration timeout = const Duration(seconds: _minimumTimeoutInSeconds), + }) async { + final List devices = []; + + final List devicesSection = await _listCoreDevices(timeout: timeout); + for (final Object? deviceObject in devicesSection) { + if (deviceObject is Map) { + devices.add(IOSCoreDevice.fromBetaJson(deviceObject, logger: _logger)); + } + } + return devices; + } + + /// Executes `devicectl` command to get list of apps installed on the device. + /// If [bundleId] is provided, it will only return apps matching the bundle + /// identifier exactly. + Future> _listInstalledApps({ + required String deviceId, + String? bundleId, + }) async { + if (!_xcode.isDevicectlInstalled) { + _logger.printError('devicectl is not installed.'); + return []; + } + + final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('core_devices.'); + final File output = tempDirectory.childFile('core_device_app_list.json'); + output.createSync(); + + final List command = [ + ..._xcode.xcrunCommand(), + 'devicectl', + 'device', + 'info', + 'apps', + '--device', + deviceId, + if (bundleId != null) + '--bundle-id', + bundleId!, + '--json-output', + output.path, + ]; + + try { + await _processUtils.run(command, throwOnError: true); + + final String stringOutput = output.readAsStringSync(); + + try { + final Object? decodeResult = (json.decode(stringOutput) as Map)['result']; + if (decodeResult is Map) { + final Object? decodeApps = decodeResult['apps']; + if (decodeApps is List) { + return decodeApps; + } + } + _logger.printError('devicectl returned unexpected JSON response: $stringOutput'); + return []; + } on FormatException { + // We failed to parse the devicectl output, or it returned junk. + _logger.printError('devicectl returned non-JSON response: $stringOutput'); + return []; + } + } on ProcessException catch (err) { + _logger.printError('Error executing devicectl: $err'); + return []; + } finally { + tempDirectory.deleteSync(recursive: true); + } + } + + @visibleForTesting + Future> getInstalledApps({ + required String deviceId, + String? bundleId, + }) async { + final List apps = []; + + final List appsData = await _listInstalledApps(deviceId: deviceId, bundleId: bundleId); + for (final Object? appObject in appsData) { + if (appObject is Map) { + apps.add(IOSCoreDeviceInstalledApp.fromBetaJson(appObject)); + } + } + return apps; + } + + Future isAppInstalled({ + required String deviceId, + required String bundleId, + }) async { + final List apps = await getInstalledApps( + deviceId: deviceId, + bundleId: bundleId, + ); + if (apps.isNotEmpty) { + return true; + } + return false; + } + + Future installApp({ + required String deviceId, + required String bundlePath, + }) async { + if (!_xcode.isDevicectlInstalled) { + _logger.printError('devicectl is not installed.'); + return false; + } + + final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('core_devices.'); + final File output = tempDirectory.childFile('install_results.json'); + output.createSync(); + + final List command = [ + ..._xcode.xcrunCommand(), + 'devicectl', + 'device', + 'install', + 'app', + '--device', + deviceId, + bundlePath, + '--json-output', + output.path, + ]; + + try { + await _processUtils.run(command, throwOnError: true); + final String stringOutput = output.readAsStringSync(); + + try { + final Object? decodeResult = (json.decode(stringOutput) as Map)['info']; + if (decodeResult is Map && decodeResult['outcome'] == 'success') { + return true; + } + _logger.printError('devicectl returned unexpected JSON response: $stringOutput'); + return false; + } on FormatException { + // We failed to parse the devicectl output, or it returned junk. + _logger.printError('devicectl returned non-JSON response: $stringOutput'); + return false; + } + } on ProcessException catch (err) { + _logger.printError('Error executing devicectl: $err'); + return false; + } finally { + tempDirectory.deleteSync(recursive: true); + } + } + + /// Uninstalls the app from the device. Will succeed even if the app is not + /// currently installed on the device. + Future uninstallApp({ + required String deviceId, + required String bundleId, + }) async { + if (!_xcode.isDevicectlInstalled) { + _logger.printError('devicectl is not installed.'); + return false; + } + + final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('core_devices.'); + final File output = tempDirectory.childFile('uninstall_results.json'); + output.createSync(); + + final List command = [ + ..._xcode.xcrunCommand(), + 'devicectl', + 'device', + 'uninstall', + 'app', + '--device', + deviceId, + bundleId, + '--json-output', + output.path, + ]; + + try { + await _processUtils.run(command, throwOnError: true); + final String stringOutput = output.readAsStringSync(); + + try { + final Object? decodeResult = (json.decode(stringOutput) as Map)['info']; + if (decodeResult is Map && decodeResult['outcome'] == 'success') { + return true; + } + _logger.printError('devicectl returned unexpected JSON response: $stringOutput'); + return false; + } on FormatException { + // We failed to parse the devicectl output, or it returned junk. + _logger.printError('devicectl returned non-JSON response: $stringOutput'); + return false; + } + } on ProcessException catch (err) { + _logger.printError('Error executing devicectl: $err'); + return false; + } finally { + tempDirectory.deleteSync(recursive: true); + } + } + + Future launchApp({ + required String deviceId, + required String bundleId, + List launchArguments = const [], + }) async { + if (!_xcode.isDevicectlInstalled) { + _logger.printError('devicectl is not installed.'); + return false; + } + + final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('core_devices.'); + final File output = tempDirectory.childFile('launch_results.json'); + output.createSync(); + + final List command = [ + ..._xcode.xcrunCommand(), + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + if (launchArguments.isNotEmpty) ...launchArguments, + '--json-output', + output.path, + ]; + + try { + await _processUtils.run(command, throwOnError: true); + final String stringOutput = output.readAsStringSync(); + + try { + final Object? decodeResult = (json.decode(stringOutput) as Map)['info']; + if (decodeResult is Map && decodeResult['outcome'] == 'success') { + return true; + } + _logger.printError('devicectl returned unexpected JSON response: $stringOutput'); + return false; + } on FormatException { + // We failed to parse the devicectl output, or it returned junk. + _logger.printError('devicectl returned non-JSON response: $stringOutput'); + return false; + } + } on ProcessException catch (err) { + _logger.printError('Error executing devicectl: $err'); + return false; + } finally { + tempDirectory.deleteSync(recursive: true); + } + } +} + +class IOSCoreDevice { + IOSCoreDevice._({ + required this.capabilities, + required this.connectionProperties, + required this.deviceProperties, + required this.hardwareProperties, + required this.coreDeviceIdentifer, + required this.visibilityClass, + }); + + /// Parse JSON from `devicectl list devices --json-output` while it's in beta preview mode. + /// + /// Example: + /// { + /// "capabilities" : [ + /// ], + /// "connectionProperties" : { + /// }, + /// "deviceProperties" : { + /// }, + /// "hardwareProperties" : { + /// }, + /// "identifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + /// "visibilityClass" : "default" + /// } + factory IOSCoreDevice.fromBetaJson( + Map data, { + required Logger logger, + }) { + final List<_IOSCoreDeviceCapability> capabilitiesList = <_IOSCoreDeviceCapability>[]; + if (data['capabilities'] is List) { + final List capabilitiesData = data['capabilities']! as List; + for (final Object? capabilityData in capabilitiesData) { + if (capabilityData != null && capabilityData is Map) { + capabilitiesList.add(_IOSCoreDeviceCapability.fromBetaJson(capabilityData)); + } + } + } + + _IOSCoreDeviceConnectionProperties? connectionProperties; + if (data['connectionProperties'] is Map) { + final Map connectionPropertiesData = data['connectionProperties']! as Map; + connectionProperties = _IOSCoreDeviceConnectionProperties.fromBetaJson( + connectionPropertiesData, + logger: logger, + ); + } + + IOSCoreDeviceProperties? deviceProperties; + if (data['deviceProperties'] is Map) { + final Map devicePropertiesData = data['deviceProperties']! as Map; + deviceProperties = IOSCoreDeviceProperties.fromBetaJson(devicePropertiesData); + } + + _IOSCoreDeviceHardwareProperties? hardwareProperties; + if (data['hardwareProperties'] is Map) { + final Map hardwarePropertiesData = data['hardwareProperties']! as Map; + hardwareProperties = _IOSCoreDeviceHardwareProperties.fromBetaJson( + hardwarePropertiesData, + logger: logger, + ); + } + + return IOSCoreDevice._( + capabilities: capabilitiesList, + connectionProperties: connectionProperties, + deviceProperties: deviceProperties, + hardwareProperties: hardwareProperties, + coreDeviceIdentifer: data['identifier']?.toString(), + visibilityClass: data['visibilityClass']?.toString(), + ); + } + + String? get udid => hardwareProperties?.udid; + + DeviceConnectionInterface? get connectionInterface { + final String? transportType = connectionProperties?.transportType; + if (transportType != null) { + if (transportType.toLowerCase() == 'localnetwork') { + return DeviceConnectionInterface.wireless; + } else if (transportType.toLowerCase() == 'wired') { + return DeviceConnectionInterface.attached; + } + } + return null; + } + + @visibleForTesting + final List<_IOSCoreDeviceCapability> capabilities; + + @visibleForTesting + final _IOSCoreDeviceConnectionProperties? connectionProperties; + + final IOSCoreDeviceProperties? deviceProperties; + + @visibleForTesting + final _IOSCoreDeviceHardwareProperties? hardwareProperties; + + final String? coreDeviceIdentifer; + final String? visibilityClass; +} + + +class _IOSCoreDeviceCapability { + _IOSCoreDeviceCapability._({ + required this.featureIdentifier, + required this.name, + }); + + /// Parse `capabilities` section of JSON from `devicectl list devices --json-output` + /// while it's in beta preview mode. + /// + /// Example: + /// "capabilities" : [ + /// { + /// "featureIdentifier" : "com.apple.coredevice.feature.spawnexecutable", + /// "name" : "Spawn Executable" + /// }, + /// { + /// "featureIdentifier" : "com.apple.coredevice.feature.launchapplication", + /// "name" : "Launch Application" + /// } + /// ] + factory _IOSCoreDeviceCapability.fromBetaJson(Map data) { + return _IOSCoreDeviceCapability._( + featureIdentifier: data['featureIdentifier']?.toString(), + name: data['name']?.toString(), + ); + } + + final String? featureIdentifier; + final String? name; +} + +class _IOSCoreDeviceConnectionProperties { + _IOSCoreDeviceConnectionProperties._({ + required this.authenticationType, + required this.isMobileDeviceOnly, + required this.lastConnectionDate, + required this.localHostnames, + required this.pairingState, + required this.potentialHostnames, + required this.transportType, + required this.tunnelIPAddress, + required this.tunnelState, + required this.tunnelTransportProtocol, + }); + + /// Parse `connectionProperties` section of JSON from `devicectl list devices --json-output` + /// while it's in beta preview mode. + /// + /// Example: + /// "connectionProperties" : { + /// "authenticationType" : "manualPairing", + /// "isMobileDeviceOnly" : false, + /// "lastConnectionDate" : "2023-06-15T15:29:00.082Z", + /// "localHostnames" : [ + /// "iPadName.coredevice.local", + /// "00001234-0001234A3C03401E.coredevice.local", + /// "12345BB5-AEDE-4A22-B653-6037262550DD.coredevice.local" + /// ], + /// "pairingState" : "paired", + /// "potentialHostnames" : [ + /// "00001234-0001234A3C03401E.coredevice.local", + /// "12345BB5-AEDE-4A22-B653-6037262550DD.coredevice.local" + /// ], + /// "transportType" : "wired", + /// "tunnelIPAddress" : "fdf1:23c4:cd56::1", + /// "tunnelState" : "connected", + /// "tunnelTransportProtocol" : "tcp" + /// } + factory _IOSCoreDeviceConnectionProperties.fromBetaJson( + Map data, { + required Logger logger, + }) { + List? localHostnames; + if (data['localHostnames'] is List) { + final List values = data['localHostnames']! as List; + try { + localHostnames = List.from(values); + } on TypeError { + logger.printTrace('Error parsing localHostnames value: $values'); + } + } + + List? potentialHostnames; + if (data['potentialHostnames'] is List) { + final List values = data['potentialHostnames']! as List; + try { + potentialHostnames = List.from(values); + } on TypeError { + logger.printTrace('Error parsing potentialHostnames value: $values'); + } + } + return _IOSCoreDeviceConnectionProperties._( + authenticationType: data['authenticationType']?.toString(), + isMobileDeviceOnly: data['isMobileDeviceOnly'] is bool? ? data['isMobileDeviceOnly'] as bool? : null, + lastConnectionDate: data['lastConnectionDate']?.toString(), + localHostnames: localHostnames, + pairingState: data['pairingState']?.toString(), + potentialHostnames: potentialHostnames, + transportType: data['transportType']?.toString(), + tunnelIPAddress: data['tunnelIPAddress']?.toString(), + tunnelState: data['tunnelState']?.toString(), + tunnelTransportProtocol: data['tunnelTransportProtocol']?.toString(), + ); + } + + final String? authenticationType; + final bool? isMobileDeviceOnly; + final String? lastConnectionDate; + final List? localHostnames; + final String? pairingState; + final List? potentialHostnames; + final String? transportType; + final String? tunnelIPAddress; + final String? tunnelState; + final String? tunnelTransportProtocol; +} + +@visibleForTesting +class IOSCoreDeviceProperties { + IOSCoreDeviceProperties._({ + required this.bootedFromSnapshot, + required this.bootedSnapshotName, + required this.bootState, + required this.ddiServicesAvailable, + required this.developerModeStatus, + required this.hasInternalOSBuild, + required this.name, + required this.osBuildUpdate, + required this.osVersionNumber, + required this.rootFileSystemIsWritable, + required this.screenViewingURL, + }); + + /// Parse `deviceProperties` section of JSON from `devicectl list devices --json-output` + /// while it's in beta preview mode. + /// + /// Example: + /// "deviceProperties" : { + /// "bootedFromSnapshot" : true, + /// "bootedSnapshotName" : "com.apple.os.update-B5336980824124F599FD39FE91016493A74331B09F475250BB010B276FE2439E3DE3537349A3A957D3FF2A4B623B4ECC", + /// "bootState" : "booted", + /// "ddiServicesAvailable" : true, + /// "developerModeStatus" : "enabled", + /// "hasInternalOSBuild" : false, + /// "name" : "iPadName", + /// "osBuildUpdate" : "21A5248v", + /// "osVersionNumber" : "17.0", + /// "rootFileSystemIsWritable" : false, + /// "screenViewingURL" : "coredevice-devices:/viewDeviceByUUID?uuid=123456BB5-AEDE-7A22-B890-1234567890DD" + /// } + factory IOSCoreDeviceProperties.fromBetaJson(Map data) { + return IOSCoreDeviceProperties._( + bootedFromSnapshot: data['bootedFromSnapshot'] is bool? ? data['bootedFromSnapshot'] as bool? : null, + bootedSnapshotName: data['bootedSnapshotName']?.toString(), + bootState: data['bootState']?.toString(), + ddiServicesAvailable: data['ddiServicesAvailable'] is bool? ? data['ddiServicesAvailable'] as bool? : null, + developerModeStatus: data['developerModeStatus']?.toString(), + hasInternalOSBuild: data['hasInternalOSBuild'] is bool? ? data['hasInternalOSBuild'] as bool? : null, + name: data['name']?.toString(), + osBuildUpdate: data['osBuildUpdate']?.toString(), + osVersionNumber: data['osVersionNumber']?.toString(), + rootFileSystemIsWritable: data['rootFileSystemIsWritable'] is bool? ? data['rootFileSystemIsWritable'] as bool? : null, + screenViewingURL: data['screenViewingURL']?.toString(), + ); + } + + final bool? bootedFromSnapshot; + final String? bootedSnapshotName; + final String? bootState; + final bool? ddiServicesAvailable; + final String? developerModeStatus; + final bool? hasInternalOSBuild; + final String? name; + final String? osBuildUpdate; + final String? osVersionNumber; + final bool? rootFileSystemIsWritable; + final String? screenViewingURL; +} + +class _IOSCoreDeviceHardwareProperties { + _IOSCoreDeviceHardwareProperties._({ + required this.cpuType, + required this.deviceType, + required this.ecid, + required this.hardwareModel, + required this.internalStorageCapacity, + required this.marketingName, + required this.platform, + required this.productType, + required this.serialNumber, + required this.supportedCPUTypes, + required this.supportedDeviceFamilies, + required this.thinningProductType, + required this.udid, + }); + + /// Parse `hardwareProperties` section of JSON from `devicectl list devices --json-output` + /// while it's in beta preview mode. + /// + /// Example: + /// "hardwareProperties" : { + /// "cpuType" : { + /// "name" : "arm64e", + /// "subType" : 2, + /// "type" : 16777228 + /// }, + /// "deviceType" : "iPad", + /// "ecid" : 12345678903408542, + /// "hardwareModel" : "J617AP", + /// "internalStorageCapacity" : 128000000000, + /// "marketingName" : "iPad Pro (11-inch) (4th generation)\"", + /// "platform" : "iOS", + /// "productType" : "iPad14,3", + /// "serialNumber" : "HC123DHCQV", + /// "supportedCPUTypes" : [ + /// { + /// "name" : "arm64e", + /// "subType" : 2, + /// "type" : 16777228 + /// }, + /// { + /// "name" : "arm64", + /// "subType" : 0, + /// "type" : 16777228 + /// } + /// ], + /// "supportedDeviceFamilies" : [ + /// 1, + /// 2 + /// ], + /// "thinningProductType" : "iPad14,3-A", + /// "udid" : "00001234-0001234A3C03401E" + /// } + factory _IOSCoreDeviceHardwareProperties.fromBetaJson( + Map data, { + required Logger logger, + }) { + _IOSCoreDeviceCPUType? cpuType; + if (data['cpuType'] is Map) { + cpuType = _IOSCoreDeviceCPUType.fromBetaJson(data['cpuType']! as Map); + } + + List<_IOSCoreDeviceCPUType>? supportedCPUTypes; + if (data['supportedCPUTypes'] is List) { + final List values = data['supportedCPUTypes']! as List; + final List<_IOSCoreDeviceCPUType> cpuTypes = <_IOSCoreDeviceCPUType>[]; + for (final Object? cpuTypeData in values) { + if (cpuTypeData is Map) { + cpuTypes.add(_IOSCoreDeviceCPUType.fromBetaJson(cpuTypeData)); + } + } + supportedCPUTypes = cpuTypes; + } + + List? supportedDeviceFamilies; + if (data['supportedDeviceFamilies'] is List) { + final List values = data['supportedDeviceFamilies']! as List; + try { + supportedDeviceFamilies = List.from(values); + } on TypeError { + logger.printTrace('Error parsing supportedDeviceFamilies value: $values'); + } + } + + return _IOSCoreDeviceHardwareProperties._( + cpuType: cpuType, + deviceType: data['deviceType']?.toString(), + ecid: data['ecid'] is int? ? data['ecid'] as int? : null, + hardwareModel: data['hardwareModel']?.toString(), + internalStorageCapacity: data['internalStorageCapacity'] is int? ? data['internalStorageCapacity'] as int? : null, + marketingName: data['marketingName']?.toString(), + platform: data['platform']?.toString(), + productType: data['productType']?.toString(), + serialNumber: data['serialNumber']?.toString(), + supportedCPUTypes: supportedCPUTypes, + supportedDeviceFamilies: supportedDeviceFamilies, + thinningProductType: data['thinningProductType']?.toString(), + udid: data['udid']?.toString(), + ); + } + + final _IOSCoreDeviceCPUType? cpuType; + final String? deviceType; + final int? ecid; + final String? hardwareModel; + final int? internalStorageCapacity; + final String? marketingName; + final String? platform; + final String? productType; + final String? serialNumber; + final List<_IOSCoreDeviceCPUType>? supportedCPUTypes; + final List? supportedDeviceFamilies; + final String? thinningProductType; + final String? udid; +} + +class _IOSCoreDeviceCPUType { + _IOSCoreDeviceCPUType._({ + this.name, + this.subType, + this.cpuType, + }); + + /// Parse `hardwareProperties.cpuType` and `hardwareProperties.supportedCPUTypes` + /// sections of JSON from `devicectl list devices --json-output` while it's in beta preview mode. + /// + /// Example: + /// "cpuType" : { + /// "name" : "arm64e", + /// "subType" : 2, + /// "type" : 16777228 + /// } + factory _IOSCoreDeviceCPUType.fromBetaJson(Map data) { + return _IOSCoreDeviceCPUType._( + name: data['name']?.toString(), + subType: data['subType'] is int? ? data['subType'] as int? : null, + cpuType: data['type'] is int? ? data['type'] as int? : null, + ); + } + + final String? name; + final int? subType; + final int? cpuType; +} + +@visibleForTesting +class IOSCoreDeviceInstalledApp { + IOSCoreDeviceInstalledApp._({ + required this.appClip, + required this.builtByDeveloper, + required this.bundleIdentifier, + required this.bundleVersion, + required this.defaultApp, + required this.hidden, + required this.internalApp, + required this.name, + required this.removable, + required this.url, + required this.version, + }); + + /// Parse JSON from `devicectl device info apps --json-output` while it's in + /// beta preview mode. + /// + /// Example: + /// { + /// "appClip" : false, + /// "builtByDeveloper" : true, + /// "bundleIdentifier" : "com.example.flutterApp", + /// "bundleVersion" : "1", + /// "defaultApp" : false, + /// "hidden" : false, + /// "internalApp" : false, + /// "name" : "Flutter App", + /// "removable" : true, + /// "url" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/", + /// "version" : "1.0.0" + /// } + factory IOSCoreDeviceInstalledApp.fromBetaJson(Map data) { + return IOSCoreDeviceInstalledApp._( + appClip: data['appClip'] is bool? ? data['appClip'] as bool? : null, + builtByDeveloper: data['builtByDeveloper'] is bool? ? data['builtByDeveloper'] as bool? : null, + bundleIdentifier: data['bundleIdentifier']?.toString(), + bundleVersion: data['bundleVersion']?.toString(), + defaultApp: data['defaultApp'] is bool? ? data['defaultApp'] as bool? : null, + hidden: data['hidden'] is bool? ? data['hidden'] as bool? : null, + internalApp: data['internalApp'] is bool? ? data['internalApp'] as bool? : null, + name: data['name']?.toString(), + removable: data['removable'] is bool? ? data['removable'] as bool? : null, + url: data['url']?.toString(), + version: data['version']?.toString(), + ); + } + + final bool? appClip; + final bool? builtByDeveloper; + final String? bundleIdentifier; + final String? bundleVersion; + final bool? defaultApp; + final bool? hidden; + final bool? internalApp; + final String? name; + final bool? removable; + final String? url; + final String? version; +} diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index 000529dad532b..85d89aceeb274 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -15,6 +15,7 @@ import '../base/io.dart'; import '../base/logger.dart'; import '../base/os.dart'; import '../base/platform.dart'; +import '../base/process.dart'; import '../base/utils.dart'; import '../base/version.dart'; import '../build_info.dart'; @@ -28,10 +29,13 @@ import '../project.dart'; import '../protocol_discovery.dart'; import '../vmservice.dart'; import 'application_package.dart'; +import 'core_devices.dart'; import 'ios_deploy.dart'; import 'ios_workflow.dart'; import 'iproxy.dart'; import 'mac.dart'; +import 'xcode_debug.dart'; +import 'xcodeproj.dart'; class IOSDevices extends PollingDeviceDiscovery { IOSDevices({ @@ -263,16 +267,21 @@ class IOSDevice extends Device { required this.connectionInterface, required this.isConnected, required this.devModeEnabled, + required this.isCoreDevice, String? sdkVersion, required Platform platform, required IOSDeploy iosDeploy, required IMobileDevice iMobileDevice, + required IOSCoreDeviceControl coreDeviceControl, + required XcodeDebug xcodeDebug, required IProxy iProxy, required Logger logger, }) : _sdkVersion = sdkVersion, _iosDeploy = iosDeploy, _iMobileDevice = iMobileDevice, + _coreDeviceControl = coreDeviceControl, + _xcodeDebug = xcodeDebug, _iproxy = iProxy, _fileSystem = fileSystem, _logger = logger, @@ -294,6 +303,8 @@ class IOSDevice extends Device { final Logger _logger; final Platform _platform; final IMobileDevice _iMobileDevice; + final IOSCoreDeviceControl _coreDeviceControl; + final XcodeDebug _xcodeDebug; final IProxy _iproxy; Version? get sdkVersion { @@ -324,6 +335,10 @@ class IOSDevice extends Device { @override bool isConnected; + /// CoreDevice is a device connectivity stack introduced in Xcode 15. Devices + /// with iOS 17 or greater are CoreDevices. + final bool isCoreDevice; + final Map _logReaders = {}; DevicePortForwarder? _portForwarder; @@ -349,10 +364,17 @@ class IOSDevice extends Device { }) async { bool result; try { - result = await _iosDeploy.isAppInstalled( - bundleId: app.id, - deviceId: id, - ); + if (isCoreDevice) { + result = await _coreDeviceControl.isAppInstalled( + bundleId: app.id, + deviceId: id, + ); + } else { + result = await _iosDeploy.isAppInstalled( + bundleId: app.id, + deviceId: id, + ); + } } on ProcessException catch (e) { _logger.printError(e.message); return false; @@ -376,13 +398,20 @@ class IOSDevice extends Device { int installationResult; try { - installationResult = await _iosDeploy.installApp( - deviceId: id, - bundlePath: bundle.path, - appDeltaDirectory: app.appDeltaDirectory, - launchArguments: [], - interfaceType: connectionInterface, - ); + if (isCoreDevice) { + installationResult = await _coreDeviceControl.installApp( + deviceId: id, + bundlePath: bundle.path, + ) ? 0 : 1; + } else { + installationResult = await _iosDeploy.installApp( + deviceId: id, + bundlePath: bundle.path, + appDeltaDirectory: app.appDeltaDirectory, + launchArguments: [], + interfaceType: connectionInterface, + ); + } } on ProcessException catch (e) { _logger.printError(e.message); return false; @@ -404,10 +433,17 @@ class IOSDevice extends Device { }) async { int uninstallationResult; try { - uninstallationResult = await _iosDeploy.uninstallApp( - deviceId: id, - bundleId: app.id, - ); + if (isCoreDevice) { + uninstallationResult = await _coreDeviceControl.uninstallApp( + deviceId: id, + bundleId: app.id, + ) ? 0 : 1; + } else { + uninstallationResult = await _iosDeploy.uninstallApp( + deviceId: id, + bundleId: app.id, + ); + } } on ProcessException catch (e) { _logger.printError(e.message); return false; @@ -434,6 +470,7 @@ class IOSDevice extends Device { bool ipv6 = false, String? userIdentifier, @visibleForTesting Duration? discoveryTimeout, + @visibleForTesting ShutdownHooks? shutdownHooks, }) async { String? packageId; if (isWirelesslyConnected && @@ -441,6 +478,18 @@ class IOSDevice extends Device { debuggingOptions.disablePortPublication) { throwToolExit('Cannot start app on wirelessly tethered iOS device. Try running again with the --publish-port flag'); } + + // TODO(vashworth): Remove after Xcode 15 and iOS 17 are in CI (https://github.com/flutter/flutter/issues/132128) + // XcodeDebug workflow is used for CoreDevices (iOS 17+ and Xcode 15+). + // Force the use of XcodeDebug workflow in CI to test from older versions + // since devicelab has not yet been updated to iOS 17 and Xcode 15. + bool forceXcodeDebugWorkflow = false; + if (debuggingOptions.usingCISystem && + debuggingOptions.debuggingEnabled && + _platform.environment['FORCE_XCODE_DEBUG']?.toLowerCase() == 'true') { + forceXcodeDebugWorkflow = true; + } + if (!prebuiltApplication) { _logger.printTrace('Building ${package.name} for $id'); @@ -451,6 +500,7 @@ class IOSDevice extends Device { targetOverride: mainPath, activeArch: cpuArchitecture, deviceID: id, + isCoreDevice: isCoreDevice || forceXcodeDebugWorkflow, ); if (!buildResult.success) { _logger.printError('Could not build the precompiled application for the device.'); @@ -477,6 +527,7 @@ class IOSDevice extends Device { platformArgs, ipv6: ipv6, interfaceType: connectionInterface, + isCoreDevice: isCoreDevice, ); Status startAppStatus = _logger.startProgress( 'Installing and launching...', @@ -516,7 +567,16 @@ class IOSDevice extends Device { logger: _logger, ); } - if (iosDeployDebugger == null) { + + if (isCoreDevice || forceXcodeDebugWorkflow) { + installationResult = await _startAppOnCoreDevice( + debuggingOptions: debuggingOptions, + package: package, + launchArguments: launchArguments, + discoveryTimeout: discoveryTimeout, + shutdownHooks: shutdownHooks ?? globals.shutdownHooks, + ) ? 0 : 1; + } else if (iosDeployDebugger == null) { installationResult = await _iosDeploy.launchApp( deviceId: id, bundlePath: bundle.path, @@ -543,10 +603,26 @@ class IOSDevice extends Device { _logger.printTrace('Application launched on the device. Waiting for Dart VM Service url.'); - final int defaultTimeout = isWirelesslyConnected ? 45 : 30; + final int defaultTimeout; + if ((isCoreDevice || forceXcodeDebugWorkflow) && debuggingOptions.debuggingEnabled) { + // Core devices with debugging enabled takes longer because this + // includes time to install and launch the app on the device. + defaultTimeout = isWirelesslyConnected ? 75 : 60; + } else if (isWirelesslyConnected) { + defaultTimeout = 45; + } else { + defaultTimeout = 30; + } + final Timer timer = Timer(discoveryTimeout ?? Duration(seconds: defaultTimeout), () { _logger.printError('The Dart VM Service was not discovered after $defaultTimeout seconds. This is taking much longer than expected...'); - + if (isCoreDevice && debuggingOptions.debuggingEnabled) { + _logger.printError( + 'Open the Xcode window the project is opened in to ensure the app ' + 'is running. If the app is not running, try selecting "Product > Run" ' + 'to fix the problem.', + ); + } // If debugging with a wireless device and the timeout is reached, remind the // user to allow local network permissions. if (isWirelesslyConnected) { @@ -564,37 +640,71 @@ class IOSDevice extends Device { Uri? localUri; if (isWirelesslyConnected) { - // Wait for Dart VM Service to start up. - final Uri? serviceURL = await vmServiceDiscovery?.uri; - if (serviceURL == null) { - await iosDeployDebugger?.stopAndDumpBacktrace(); - await dispose(); - return LaunchResult.failed(); - } + // When using a CoreDevice, device logs are unavailable and therefore + // cannot be used to get the Dart VM url. Instead, get the Dart VM + // Service by finding services matching the app bundle id and the + // device name. + // + // If not using a CoreDevice, wait for the Dart VM url to be discovered + // via logs and then get the Dart VM Service by finding services matching + // the app bundle id and the Dart VM port. + // + // Then in both cases, get the device IP from the Dart VM Service to + // construct the Dart VM url using the device IP as the host. + if (isCoreDevice) { + localUri = await MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch( + packageId, + this, + usesIpv6: ipv6, + useDeviceIPAsHost: true, + ); + } else { + // Wait for Dart VM Service to start up. + final Uri? serviceURL = await vmServiceDiscovery?.uri; + if (serviceURL == null) { + await iosDeployDebugger?.stopAndDumpBacktrace(); + await dispose(); + return LaunchResult.failed(); + } - // If Dart VM Service URL with the device IP is not found within 5 seconds, - // change the status message to prompt users to click Allow. Wait 5 seconds because it - // should only show this message if they have not already approved the permissions. - // MDnsVmServiceDiscovery usually takes less than 5 seconds to find it. - final Timer mDNSLookupTimer = Timer(const Duration(seconds: 5), () { - startAppStatus.stop(); - startAppStatus = _logger.startProgress( - 'Waiting for approval of local network permissions...', + // If Dart VM Service URL with the device IP is not found within 5 seconds, + // change the status message to prompt users to click Allow. Wait 5 seconds because it + // should only show this message if they have not already approved the permissions. + // MDnsVmServiceDiscovery usually takes less than 5 seconds to find it. + final Timer mDNSLookupTimer = Timer(const Duration(seconds: 5), () { + startAppStatus.stop(); + startAppStatus = _logger.startProgress( + 'Waiting for approval of local network permissions...', + ); + }); + + // Get Dart VM Service URL with the device IP as the host. + localUri = await MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch( + packageId, + this, + usesIpv6: ipv6, + deviceVmservicePort: serviceURL.port, + useDeviceIPAsHost: true, ); - }); - - // Get Dart VM Service URL with the device IP as the host. - localUri = await MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch( - packageId, - this, - usesIpv6: ipv6, - deviceVmservicePort: serviceURL.port, - useDeviceIPAsHost: true, - ); - mDNSLookupTimer.cancel(); + mDNSLookupTimer.cancel(); + } } else { - localUri = await vmServiceDiscovery?.uri; + if ((isCoreDevice || forceXcodeDebugWorkflow) && vmServiceDiscovery != null) { + // When searching for the Dart VM url, search for it via ProtocolDiscovery + // (device logs) and mDNS simultaneously, since both can be flaky at times. + final Future vmUrlFromMDns = MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch( + packageId, + this, + usesIpv6: ipv6, + ); + final Future vmUrlFromLogs = vmServiceDiscovery.uri; + localUri = await Future.any( + >[vmUrlFromMDns, vmUrlFromLogs] + ); + } else { + localUri = await vmServiceDiscovery?.uri; + } } timer.cancel(); if (localUri == null) { @@ -613,6 +723,110 @@ class IOSDevice extends Device { } } + /// Starting with Xcode 15 and iOS 17, `ios-deploy` stopped working due to + /// the new CoreDevice connectivity stack. Previously, `ios-deploy` was used + /// to install the app, launch the app, and start `debugserver`. + /// Xcode 15 introduced a new command line tool called `devicectl` that + /// includes much of the functionality supplied by `ios-deploy`. However, + /// `devicectl` lacks the ability to start a `debugserver` and therefore `ptrace`, which are needed + /// for debug mode due to using a JIT Dart VM. + /// + /// Therefore, when starting an app on a CoreDevice, use `devicectl` when + /// debugging is not enabled. Otherwise, use Xcode automation. + Future _startAppOnCoreDevice({ + required DebuggingOptions debuggingOptions, + required IOSApp package, + required List launchArguments, + required ShutdownHooks shutdownHooks, + @visibleForTesting Duration? discoveryTimeout, + }) async { + if (!debuggingOptions.debuggingEnabled) { + // Release mode + + // Install app to device + final bool installSuccess = await _coreDeviceControl.installApp( + deviceId: id, + bundlePath: package.deviceBundlePath, + ); + if (!installSuccess) { + return installSuccess; + } + + // Launch app to device + final bool launchSuccess = await _coreDeviceControl.launchApp( + deviceId: id, + bundleId: package.id, + launchArguments: launchArguments, + ); + + return launchSuccess; + } else { + _logger.printStatus( + 'You may be prompted to give access to control Xcode. Flutter uses Xcode ' + 'to run your app. If access is not allowed, you can change this through ' + 'your Settings > Privacy & Security > Automation.', + ); + final int launchTimeout = isWirelesslyConnected ? 45 : 30; + final Timer timer = Timer(discoveryTimeout ?? Duration(seconds: launchTimeout), () { + _logger.printError( + 'Xcode is taking longer than expected to start debugging the app. ' + 'Ensure the project is opened in Xcode.', + ); + }); + + XcodeDebugProject debugProject; + + if (package is PrebuiltIOSApp) { + debugProject = await _xcodeDebug.createXcodeProjectWithCustomBundle( + package.deviceBundlePath, + templateRenderer: globals.templateRenderer, + verboseLogging: _logger.isVerbose, + ); + } else if (package is BuildableIOSApp) { + final IosProject project = package.project; + final XcodeProjectInfo? projectInfo = await project.projectInfo(); + if (projectInfo == null) { + globals.printError('Xcode project not found.'); + return false; + } + if (project.xcodeWorkspace == null) { + globals.printError('Unable to get Xcode workspace.'); + return false; + } + final String? scheme = projectInfo.schemeFor(debuggingOptions.buildInfo); + if (scheme == null) { + projectInfo.reportFlavorNotFoundAndExit(); + } + + debugProject = XcodeDebugProject( + scheme: scheme, + xcodeProject: project.xcodeProject, + xcodeWorkspace: project.xcodeWorkspace!, + verboseLogging: _logger.isVerbose, + ); + } else { + // This should not happen. Currently, only PrebuiltIOSApp and + // BuildableIOSApp extend from IOSApp. + _logger.printError('IOSApp type ${package.runtimeType} is not recognized.'); + return false; + } + + final bool debugSuccess = await _xcodeDebug.debugApp( + project: debugProject, + deviceId: id, + launchArguments:launchArguments, + ); + timer.cancel(); + + // Kill Xcode on shutdown when running from CI + if (debuggingOptions.usingCISystem) { + shutdownHooks.addShutdownHook(() => _xcodeDebug.exit(force: true)); + } + + return debugSuccess; + } + } + @override Future stopApp( ApplicationPackage? app, { @@ -623,6 +837,9 @@ class IOSDevice extends Device { if (deployDebugger != null && deployDebugger.debuggerAttached) { return deployDebugger.exit(); } + if (_xcodeDebug.debugStarted) { + return _xcodeDebug.exit(); + } return false; } @@ -669,7 +886,14 @@ class IOSDevice extends Device { void clearLogs() { } @override - bool get supportsScreenshot => _iMobileDevice.isInstalled; + bool get supportsScreenshot { + if (isCoreDevice) { + // `idevicescreenshot` stopped working with iOS 17 / Xcode 15 + // (https://github.com/flutter/flutter/issues/128598). + return false; + } + return _iMobileDevice.isInstalled; + } @override Future takeScreenshot(File outputFile) async { @@ -757,14 +981,18 @@ class IOSDeviceLogReader extends DeviceLogReader { this._majorSdkVersion, this._deviceId, this.name, + this._isWirelesslyConnected, + this._isCoreDevice, String appName, - bool usingCISystem, - ) : // Match for lines for the runner in syslog. + bool usingCISystem, { + bool forceXcodeDebug = false, + }) : // Match for lines for the runner in syslog. // // iOS 9 format: Runner[297] : // iOS 10 format: Runner(Flutter)[297] : _runnerLineRegex = RegExp(appName + r'(\(Flutter\))?\[[\d]+\] <[A-Za-z]+>: '), - _usingCISystem = usingCISystem; + _usingCISystem = usingCISystem, + _forceXcodeDebug = forceXcodeDebug; /// Create a new [IOSDeviceLogReader]. factory IOSDeviceLogReader.create({ @@ -779,8 +1007,11 @@ class IOSDeviceLogReader extends DeviceLogReader { device.majorSdkVersion, device.id, device.name, + device.isWirelesslyConnected, + device.isCoreDevice, appName, usingCISystem, + forceXcodeDebug: device._platform.environment['FORCE_XCODE_DEBUG']?.toLowerCase() == 'true', ); } @@ -790,6 +1021,8 @@ class IOSDeviceLogReader extends DeviceLogReader { bool useSyslog = true, bool usingCISystem = false, int? majorSdkVersion, + bool isWirelesslyConnected = false, + bool isCoreDevice = false, }) { final int sdkVersion; if (majorSdkVersion != null) { @@ -798,16 +1031,22 @@ class IOSDeviceLogReader extends DeviceLogReader { sdkVersion = useSyslog ? 12 : 13; } return IOSDeviceLogReader._( - iMobileDevice, sdkVersion, '1234', 'test', 'Runner', usingCISystem); + iMobileDevice, sdkVersion, '1234', 'test', isWirelesslyConnected, isCoreDevice, 'Runner', usingCISystem); } @override final String name; final int _majorSdkVersion; final String _deviceId; + final bool _isWirelesslyConnected; + final bool _isCoreDevice; final IMobileDevice _iMobileDevice; final bool _usingCISystem; + // TODO(vashworth): Remove after Xcode 15 and iOS 17 are in CI (https://github.com/flutter/flutter/issues/132128) + /// Whether XcodeDebug workflow is being forced. + final bool _forceXcodeDebug; + // Matches a syslog line from the runner. RegExp _runnerLineRegex; @@ -832,7 +1071,8 @@ class IOSDeviceLogReader extends DeviceLogReader { // Sometimes (race condition?) we try to send a log after the controller has // been closed. See https://github.com/flutter/flutter/issues/99021 for more // context. - void _addToLinesController(String message, IOSDeviceLogSource source) { + @visibleForTesting + void addToLinesController(String message, IOSDeviceLogSource source) { if (!linesController.isClosed) { if (_excludeLog(message, source)) { return; @@ -841,30 +1081,53 @@ class IOSDeviceLogReader extends DeviceLogReader { } } - /// Used to track messages prefixed with "flutter:" when [useBothLogDeviceReaders] - /// is true. - final List _streamFlutterMessages = []; + /// Used to track messages prefixed with "flutter:" from the fallback log source. + final List _fallbackStreamFlutterMessages = []; - /// When using both `idevicesyslog` and `ios-deploy`, exclude logs with the - /// "flutter:" prefix if they have already been added to the stream. This is - /// to prevent duplicates from being printed. - /// - /// If a message does not have the prefix, exclude it if the message's - /// source is `idevicesyslog`. This is done because `ios-deploy` and - /// `idevicesyslog` often have different prefixes on non-flutter messages - /// and are often not critical for CI tests. + /// Used to track if a message prefixed with "flutter:" has been received from the primary log. + bool primarySourceFlutterLogReceived = false; + + /// There are three potential logging sources: `idevicesyslog`, `ios-deploy`, + /// and Unified Logging (Dart VM). When using more than one of these logging + /// sources at a time, prefer to use the primary source. However, if the + /// primary source is not working, use the fallback. bool _excludeLog(String message, IOSDeviceLogSource source) { - if (!useBothLogDeviceReaders) { + // If no fallback, don't exclude any logs. + if (logSources.fallbackSource == null) { return false; } - if (message.startsWith('flutter:')) { - if (_streamFlutterMessages.contains(message)) { + + // If log is from primary source, don't exclude it unless the fallback was + // quicker and added the message first. + if (source == logSources.primarySource) { + if (!primarySourceFlutterLogReceived && message.startsWith('flutter:')) { + primarySourceFlutterLogReceived = true; + } + + // If the message was already added by the fallback, exclude it to + // prevent duplicates. + final bool foundAndRemoved = _fallbackStreamFlutterMessages.remove(message); + if (foundAndRemoved) { return true; } - _streamFlutterMessages.add(message); - } else if (source == IOSDeviceLogSource.idevicesyslog) { + return false; + } + + // If a flutter log was received from the primary source, that means it's + // working so don't use any messages from the fallback. + if (primarySourceFlutterLogReceived) { + return true; + } + + // When using logs from fallbacks, skip any logs not prefixed with "flutter:". + // This is done because different sources often have different prefixes for + // non-flutter messages, which makes duplicate matching difficult. Also, + // non-flutter messages are not critical for CI tests. + if (!message.startsWith('flutter:')) { return true; } + + _fallbackStreamFlutterMessages.add(message); return false; } @@ -887,12 +1150,91 @@ class IOSDeviceLogReader extends DeviceLogReader { static const int minimumUniversalLoggingSdkVersion = 13; - /// Listen to Dart VM for logs on iOS 13 or greater. + /// Determine the primary and fallback source for device logs. /// - /// Only send logs to stream if [_iosDeployDebugger] is null or - /// the [_iosDeployDebugger] debugger is not attached. - Future _listenToUnifiedLoggingEvents(FlutterVmService connectedVmService) async { + /// There are three potential logging sources: `idevicesyslog`, `ios-deploy`, + /// and Unified Logging (Dart VM). + @visibleForTesting + _IOSDeviceLogSources get logSources { + // `ios-deploy` stopped working with iOS 17 / Xcode 15, so use `idevicesyslog` instead. + // However, `idevicesyslog` is sometimes unreliable so use Dart VM as a fallback. + // Also, `idevicesyslog` does not work with iOS 17 wireless devices, so use the + // Dart VM for wireless devices. + if (_isCoreDevice || _forceXcodeDebug) { + if (_isWirelesslyConnected) { + return _IOSDeviceLogSources( + primarySource: IOSDeviceLogSource.unifiedLogging, + ); + } + return _IOSDeviceLogSources( + primarySource: IOSDeviceLogSource.idevicesyslog, + fallbackSource: IOSDeviceLogSource.unifiedLogging, + ); + } + + // Use `idevicesyslog` for iOS 12 or less. + // Syslog stopped working on iOS 13 (https://github.com/flutter/flutter/issues/41133). + // However, from at least iOS 16, it has began working again. It's unclear + // why it started working again. if (_majorSdkVersion < minimumUniversalLoggingSdkVersion) { + return _IOSDeviceLogSources( + primarySource: IOSDeviceLogSource.idevicesyslog, + ); + } + + // Use `idevicesyslog` as a fallback to `ios-deploy` when debugging from + // CI system since sometimes `ios-deploy` does not return the device logs: + // https://github.com/flutter/flutter/issues/121231 + if (_usingCISystem && _majorSdkVersion >= 16) { + return _IOSDeviceLogSources( + primarySource: IOSDeviceLogSource.iosDeploy, + fallbackSource: IOSDeviceLogSource.idevicesyslog, + ); + } + + // Use `ios-deploy` to stream logs from the device when the device is not a + // CoreDevice and has iOS 13 or greater. + // When using `ios-deploy` and the Dart VM, prefer the more complete logs + // from the attached debugger, if available. + if (connectedVMService != null && (_iosDeployDebugger == null || !_iosDeployDebugger!.debuggerAttached)) { + return _IOSDeviceLogSources( + primarySource: IOSDeviceLogSource.unifiedLogging, + fallbackSource: IOSDeviceLogSource.iosDeploy, + ); + } + return _IOSDeviceLogSources( + primarySource: IOSDeviceLogSource.iosDeploy, + fallbackSource: IOSDeviceLogSource.unifiedLogging, + ); + } + + /// Whether `idevicesyslog` is used as either the primary or fallback source for device logs. + @visibleForTesting + bool get useSyslogLogging { + return logSources.primarySource == IOSDeviceLogSource.idevicesyslog || + logSources.fallbackSource == IOSDeviceLogSource.idevicesyslog; + } + + /// Whether the Dart VM is used as either the primary or fallback source for device logs. + /// + /// Unified Logging only works after the Dart VM has been connected to. + @visibleForTesting + bool get useUnifiedLogging { + return logSources.primarySource == IOSDeviceLogSource.unifiedLogging || + logSources.fallbackSource == IOSDeviceLogSource.unifiedLogging; + } + + + /// Whether `ios-deploy` is used as either the primary or fallback source for device logs. + @visibleForTesting + bool get useIOSDeployLogging { + return logSources.primarySource == IOSDeviceLogSource.iosDeploy || + logSources.fallbackSource == IOSDeviceLogSource.iosDeploy; + } + + /// Listen to Dart VM for logs on iOS 13 or greater. + Future _listenToUnifiedLoggingEvents(FlutterVmService connectedVmService) async { + if (!useUnifiedLogging) { return; } try { @@ -909,13 +1251,9 @@ class IOSDeviceLogReader extends DeviceLogReader { } void logMessage(vm_service.Event event) { - if (_iosDeployDebugger != null && _iosDeployDebugger!.debuggerAttached) { - // Prefer the more complete logs from the attached debugger. - return; - } final String message = processVmServiceMessage(event); if (message.isNotEmpty) { - _addToLinesController(message, IOSDeviceLogSource.unifiedLogging); + addToLinesController(message, IOSDeviceLogSource.unifiedLogging); } } @@ -931,7 +1269,7 @@ class IOSDeviceLogReader extends DeviceLogReader { /// Send messages from ios-deploy debugger stream to device log reader stream. set debuggerStream(IOSDeployDebugger? debugger) { // Logging is gathered from syslog on iOS earlier than 13. - if (_majorSdkVersion < minimumUniversalLoggingSdkVersion) { + if (!useIOSDeployLogging) { return; } _iosDeployDebugger = debugger; @@ -940,7 +1278,7 @@ class IOSDeviceLogReader extends DeviceLogReader { } // Add the debugger logs to the controller created on initialization. _loggingSubscriptions.add(debugger.logLines.listen( - (String line) => _addToLinesController( + (String line) => addToLinesController( _debuggerLineHandler(line), IOSDeviceLogSource.iosDeploy, ), @@ -954,22 +1292,10 @@ class IOSDeviceLogReader extends DeviceLogReader { // Strip off the logging metadata (leave the category), or just echo the line. String _debuggerLineHandler(String line) => _debuggerLoggingRegex.firstMatch(line)?.group(1) ?? line; - /// Use both logs from `idevicesyslog` and `ios-deploy` when debugging from CI system - /// since sometimes `ios-deploy` does not return the device logs: - /// https://github.com/flutter/flutter/issues/121231 - @visibleForTesting - bool get useBothLogDeviceReaders { - return _usingCISystem && _majorSdkVersion >= 16; - } - /// Start and listen to idevicesyslog to get device logs for iOS versions /// prior to 13 or if [useBothLogDeviceReaders] is true. void _listenToSysLog() { - // Syslog stopped working on iOS 13 (https://github.com/flutter/flutter/issues/41133). - // However, from at least iOS 16, it has began working again. It's unclear - // why it started working again so only use syslogs for iOS versions prior - // to 13 unless [useBothLogDeviceReaders] is true. - if (!useBothLogDeviceReaders && _majorSdkVersion >= minimumUniversalLoggingSdkVersion) { + if (!useSyslogLogging) { return; } _iMobileDevice.startLogger(_deviceId).then((Process process) { @@ -982,7 +1308,7 @@ class IOSDeviceLogReader extends DeviceLogReader { // When using both log readers, do not close the stream on exit. // This is to allow ios-deploy to be the source of authority to close // the stream. - if (useBothLogDeviceReaders && debuggerStream != null) { + if (useSyslogLogging && useIOSDeployLogging && debuggerStream != null) { return; } linesController.close(); @@ -1007,7 +1333,7 @@ class IOSDeviceLogReader extends DeviceLogReader { return (String line) { if (printing) { if (!_anyLineRegex.hasMatch(line)) { - _addToLinesController(decodeSyslog(line), IOSDeviceLogSource.idevicesyslog); + addToLinesController(decodeSyslog(line), IOSDeviceLogSource.idevicesyslog); return; } @@ -1019,7 +1345,7 @@ class IOSDeviceLogReader extends DeviceLogReader { if (match != null) { final String logLine = line.substring(match.end); // Only display the log line after the initial device and executable information. - _addToLinesController(decodeSyslog(logLine), IOSDeviceLogSource.idevicesyslog); + addToLinesController(decodeSyslog(logLine), IOSDeviceLogSource.idevicesyslog); printing = true; } }; @@ -1044,6 +1370,16 @@ enum IOSDeviceLogSource { unifiedLogging, } +class _IOSDeviceLogSources { + _IOSDeviceLogSources({ + required this.primarySource, + this.fallbackSource, + }); + + final IOSDeviceLogSource primarySource; + final IOSDeviceLogSource? fallbackSource; +} + /// A [DevicePortForwarder] specialized for iOS usage with iproxy. class IOSDevicePortForwarder extends DevicePortForwarder { diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart index cbd1f89c38aec..ecca27b24f8a5 100644 --- a/packages/flutter_tools/lib/src/ios/mac.dart +++ b/packages/flutter_tools/lib/src/ios/mac.dart @@ -116,6 +116,7 @@ Future buildXcodeProject({ DarwinArch? activeArch, bool codesign = true, String? deviceID, + bool isCoreDevice = false, bool configOnly = false, XcodeBuildAction buildAction = XcodeBuildAction.build, }) async { @@ -224,6 +225,7 @@ Future buildXcodeProject({ project: project, targetOverride: targetOverride, buildInfo: buildInfo, + usingCoreDevice: isCoreDevice, ); await processPodsIfNeeded(project.ios, getIosBuildDirectory(), buildInfo.mode); if (configOnly) { diff --git a/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart b/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart index 2ef75a209de2a..0e7c42b9f34d2 100644 --- a/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart +++ b/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart @@ -35,6 +35,7 @@ Future updateGeneratedXcodeProperties({ String? targetOverride, bool useMacOSConfig = false, String? buildDirOverride, + bool usingCoreDevice = false, }) async { final List xcodeBuildSettings = await _xcodeBuildSettingsLines( project: project, @@ -42,6 +43,7 @@ Future updateGeneratedXcodeProperties({ targetOverride: targetOverride, useMacOSConfig: useMacOSConfig, buildDirOverride: buildDirOverride, + usingCoreDevice: usingCoreDevice, ); _updateGeneratedXcodePropertiesFile( @@ -143,6 +145,7 @@ Future> _xcodeBuildSettingsLines({ String? targetOverride, bool useMacOSConfig = false, String? buildDirOverride, + bool usingCoreDevice = false, }) async { final List xcodeBuildSettings = []; @@ -170,6 +173,12 @@ Future> _xcodeBuildSettingsLines({ final String buildNumber = parsedBuildNumber(manifest: project.manifest, buildInfo: buildInfo) ?? '1'; xcodeBuildSettings.add('FLUTTER_BUILD_NUMBER=$buildNumber'); + // CoreDevices in debug and profile mode are launched, but not built, via Xcode. + // Set the BUILD_DIR so Xcode knows where to find the app bundle to launch. + if (usingCoreDevice && !buildInfo.isRelease) { + xcodeBuildSettings.add('BUILD_DIR=${globals.fs.path.absolute(getIosBuildDirectory())}'); + } + final LocalEngineInfo? localEngineInfo = globals.artifacts?.localEngineInfo; if (localEngineInfo != null) { final String engineOutPath = localEngineInfo.engineOutPath; diff --git a/packages/flutter_tools/lib/src/ios/xcode_debug.dart b/packages/flutter_tools/lib/src/ios/xcode_debug.dart new file mode 100644 index 0000000000000..e1b503643573c --- /dev/null +++ b/packages/flutter_tools/lib/src/ios/xcode_debug.dart @@ -0,0 +1,485 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:process/process.dart'; + +import '../base/error_handling_io.dart'; +import '../base/file_system.dart'; +import '../base/io.dart'; +import '../base/logger.dart'; +import '../base/process.dart'; +import '../base/template.dart'; +import '../convert.dart'; +import '../macos/xcode.dart'; +import '../template.dart'; + +/// A class to handle interacting with Xcode via OSA (Open Scripting Architecture) +/// Scripting to debug Flutter applications. +class XcodeDebug { + XcodeDebug({ + required Logger logger, + required ProcessManager processManager, + required Xcode xcode, + required FileSystem fileSystem, + }) : _logger = logger, + _processUtils = ProcessUtils(logger: logger, processManager: processManager), + _xcode = xcode, + _fileSystem = fileSystem; + + final ProcessUtils _processUtils; + final Logger _logger; + final Xcode _xcode; + final FileSystem _fileSystem; + + /// Process to start Xcode's debug action. + @visibleForTesting + Process? startDebugActionProcess; + + /// Information about the project that is currently being debugged. + @visibleForTesting + XcodeDebugProject? currentDebuggingProject; + + /// Whether the debug action has been started. + bool get debugStarted => currentDebuggingProject != null; + + /// Install, launch, and start a debug session for app through Xcode interface, + /// automated by OSA scripting. First checks if the project is opened in + /// Xcode. If it isn't, open it with the `open` command. + /// + /// The OSA script waits until the project is opened and the debug action + /// has started. It does not wait for the app to install, launch, or start + /// the debug session. + Future debugApp({ + required XcodeDebugProject project, + required String deviceId, + required List launchArguments, + }) async { + + // If project is not already opened in Xcode, open it. + if (!await _isProjectOpenInXcode(project: project)) { + final bool openResult = await _openProjectInXcode(xcodeWorkspace: project.xcodeWorkspace); + if (!openResult) { + return openResult; + } + } + + currentDebuggingProject = project; + StreamSubscription? stdoutSubscription; + StreamSubscription? stderrSubscription; + try { + startDebugActionProcess = await _processUtils.start( + [ + ..._xcode.xcrunCommand(), + 'osascript', + '-l', + 'JavaScript', + _xcode.xcodeAutomationScriptPath, + 'debug', + '--xcode-path', + _xcode.xcodeAppPath, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--device-id', + deviceId, + '--scheme', + project.scheme, + '--skip-building', + '--launch-args', + json.encode(launchArguments), + if (project.verboseLogging) '--verbose', + ], + ); + + final StringBuffer stdoutBuffer = StringBuffer(); + stdoutSubscription = startDebugActionProcess!.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + _logger.printTrace(line); + stdoutBuffer.write(line); + }); + + final StringBuffer stderrBuffer = StringBuffer(); + bool permissionWarningPrinted = false; + // console.log from the script are found in the stderr + stderrSubscription = startDebugActionProcess!.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + _logger.printTrace('stderr: $line'); + stderrBuffer.write(line); + + // This error may occur if Xcode automation has not been allowed. + // Example: Failed to get workspace: Error: An error occurred. + if (!permissionWarningPrinted && line.contains('Failed to get workspace') && line.contains('An error occurred')) { + _logger.printError( + 'There was an error finding the project in Xcode. Ensure permission ' + 'has been given to control Xcode in Settings > Privacy & Security > Automation.', + ); + permissionWarningPrinted = true; + } + }); + + final int exitCode = await startDebugActionProcess!.exitCode.whenComplete(() async { + await stdoutSubscription?.cancel(); + await stderrSubscription?.cancel(); + startDebugActionProcess = null; + }); + + if (exitCode != 0) { + _logger.printError('Error executing osascript: $exitCode\n$stderrBuffer'); + return false; + } + + final XcodeAutomationScriptResponse? response = parseScriptResponse( + stdoutBuffer.toString(), + ); + if (response == null) { + return false; + } + if (response.status == false) { + _logger.printError('Error starting debug session in Xcode: ${response.errorMessage}'); + return false; + } + if (response.debugResult == null) { + _logger.printError('Unable to get debug results from response: $stdoutBuffer'); + return false; + } + if (response.debugResult?.status != 'running') { + _logger.printError( + 'Unexpected debug results: \n' + ' Status: ${response.debugResult?.status}\n' + ' Completed: ${response.debugResult?.completed}\n' + ' Error Message: ${response.debugResult?.errorMessage}\n' + ); + return false; + } + return true; + } on ProcessException catch (exception) { + _logger.printError('Error executing osascript: $exitCode\n$exception'); + await stdoutSubscription?.cancel(); + await stderrSubscription?.cancel(); + startDebugActionProcess = null; + + return false; + } + } + + /// Kills [startDebugActionProcess] if it's still running. If [force] is true, it + /// will kill all Xcode app processes. Otherwise, it will stop the debug + /// session in Xcode. If the project is temporary, it will close the Xcode + /// window of the project and then delete the project. + Future exit({ + bool force = false, + @visibleForTesting + bool skipDelay = false, + }) async { + final bool success = (startDebugActionProcess == null) || startDebugActionProcess!.kill(); + + if (force) { + await _forceExitXcode(); + if (currentDebuggingProject != null) { + final XcodeDebugProject project = currentDebuggingProject!; + if (project.isTemporaryProject) { + // Only delete if it exists. This is to prevent crashes when racing + // with shutdown hooks to delete temporary files. + ErrorHandlingFileSystem.deleteIfExists( + project.xcodeProject.parent, + recursive: true, + ); + } + currentDebuggingProject = null; + } + } + + if (currentDebuggingProject != null) { + final XcodeDebugProject project = currentDebuggingProject!; + await stopDebuggingApp( + project: project, + closeXcode: project.isTemporaryProject, + ); + + if (project.isTemporaryProject) { + // Wait a couple seconds before deleting the project. If project is + // still opened in Xcode and it's deleted, it will prompt the user to + // restore it. + if (!skipDelay) { + await Future.delayed(const Duration(seconds: 2)); + } + + try { + project.xcodeProject.parent.deleteSync(recursive: true); + } on FileSystemException { + _logger.printError('Failed to delete temporary Xcode project: ${project.xcodeProject.parent.path}'); + } + } + currentDebuggingProject = null; + } + + return success; + } + + /// Kill all opened Xcode applications. + Future _forceExitXcode() async { + final RunResult result = await _processUtils.run( + [ + 'killall', + '-9', + 'Xcode', + ], + ); + + if (result.exitCode != 0) { + _logger.printError('Error killing Xcode: ${result.exitCode}\n${result.stderr}'); + return false; + } + return true; + } + + Future _isProjectOpenInXcode({ + required XcodeDebugProject project, + }) async { + + final RunResult result = await _processUtils.run( + [ + ..._xcode.xcrunCommand(), + 'osascript', + '-l', + 'JavaScript', + _xcode.xcodeAutomationScriptPath, + 'check-workspace-opened', + '--xcode-path', + _xcode.xcodeAppPath, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + if (project.verboseLogging) '--verbose', + ], + ); + + if (result.exitCode != 0) { + _logger.printError('Error executing osascript: ${result.exitCode}\n${result.stderr}'); + return false; + } + + final XcodeAutomationScriptResponse? response = parseScriptResponse(result.stdout); + if (response == null) { + return false; + } + if (response.status == false) { + _logger.printTrace('Error checking if project opened in Xcode: ${response.errorMessage}'); + return false; + } + return true; + } + + @visibleForTesting + XcodeAutomationScriptResponse? parseScriptResponse(String results) { + try { + final Object decodeResult = json.decode(results) as Object; + if (decodeResult is Map) { + final XcodeAutomationScriptResponse response = XcodeAutomationScriptResponse.fromJson(decodeResult); + // Status should always be found + if (response.status != null) { + return response; + } + } + _logger.printError('osascript returned unexpected JSON response: $results'); + return null; + } on FormatException { + _logger.printError('osascript returned non-JSON response: $results'); + return null; + } + } + + Future _openProjectInXcode({ + required Directory xcodeWorkspace, + }) async { + try { + await _processUtils.run( + [ + 'open', + '-a', + _xcode.xcodeAppPath, + '-g', // Do not bring the application to the foreground. + '-j', // Launches the app hidden. + xcodeWorkspace.path + ], + throwOnError: true, + ); + return true; + } on ProcessException catch (error, stackTrace) { + _logger.printError('$error', stackTrace: stackTrace); + } + return false; + } + + /// Using OSA Scripting, stop the debug session in Xcode. + /// + /// If [closeXcode] is true, it will close the Xcode window that has the + /// project opened. If [promptToSaveOnClose] is true, it will ask the user if + /// they want to save any changes before it closes. + Future stopDebuggingApp({ + required XcodeDebugProject project, + bool closeXcode = false, + bool promptToSaveOnClose = false, + }) async { + final RunResult result = await _processUtils.run( + [ + ..._xcode.xcrunCommand(), + 'osascript', + '-l', + 'JavaScript', + _xcode.xcodeAutomationScriptPath, + 'stop', + '--xcode-path', + _xcode.xcodeAppPath, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + if (closeXcode) '--close-window', + if (promptToSaveOnClose) '--prompt-to-save', + if (project.verboseLogging) '--verbose', + ], + ); + + if (result.exitCode != 0) { + _logger.printError('Error executing osascript: ${result.exitCode}\n${result.stderr}'); + return false; + } + + final XcodeAutomationScriptResponse? response = parseScriptResponse(result.stdout); + if (response == null) { + return false; + } + if (response.status == false) { + _logger.printError('Error stopping app in Xcode: ${response.errorMessage}'); + return false; + } + return true; + } + + /// Create a temporary empty Xcode project with the application bundle + /// location explicitly set. + Future createXcodeProjectWithCustomBundle( + String deviceBundlePath, { + required TemplateRenderer templateRenderer, + @visibleForTesting + Directory? projectDestination, + bool verboseLogging = false, + }) async { + final Directory tempXcodeProject = projectDestination ?? _fileSystem.systemTempDirectory.createTempSync('flutter_empty_xcode.'); + + final Template template = await Template.fromName( + _fileSystem.path.join('xcode', 'ios', 'custom_application_bundle'), + fileSystem: _fileSystem, + templateManifest: null, + logger: _logger, + templateRenderer: templateRenderer, + ); + + template.render( + tempXcodeProject, + { + 'applicationBundlePath': deviceBundlePath + }, + printStatusWhenWriting: false, + ); + + return XcodeDebugProject( + scheme: 'Runner', + xcodeProject: tempXcodeProject.childDirectory('Runner.xcodeproj'), + xcodeWorkspace: tempXcodeProject.childDirectory('Runner.xcworkspace'), + isTemporaryProject: true, + verboseLogging: verboseLogging, + ); + } +} + +@visibleForTesting +class XcodeAutomationScriptResponse { + XcodeAutomationScriptResponse._({ + this.status, + this.errorMessage, + this.debugResult, + }); + + factory XcodeAutomationScriptResponse.fromJson(Map data) { + XcodeAutomationScriptDebugResult? debugResult; + if (data['debugResult'] != null && data['debugResult'] is Map) { + debugResult = XcodeAutomationScriptDebugResult.fromJson( + data['debugResult']! as Map, + ); + } + return XcodeAutomationScriptResponse._( + status: data['status'] is bool? ? data['status'] as bool? : null, + errorMessage: data['errorMessage']?.toString(), + debugResult: debugResult, + ); + } + + final bool? status; + final String? errorMessage; + final XcodeAutomationScriptDebugResult? debugResult; +} + +@visibleForTesting +class XcodeAutomationScriptDebugResult { + XcodeAutomationScriptDebugResult._({ + required this.completed, + required this.status, + required this.errorMessage, + }); + + factory XcodeAutomationScriptDebugResult.fromJson(Map data) { + return XcodeAutomationScriptDebugResult._( + completed: data['completed'] is bool? ? data['completed'] as bool? : null, + status: data['status']?.toString(), + errorMessage: data['errorMessage']?.toString(), + ); + } + + /// Whether this scheme action has completed (sucessfully or otherwise). Will + /// be false if still running. + final bool? completed; + + /// The status of the debug action. Potential statuses include: + /// `not yet started`, `โ€Œrunning`, `โ€Œcancelled`, `โ€Œfailed`, `โ€Œerror occurred`, + /// and `โ€Œsucceeded`. + /// + /// Only the status of `โ€Œrunning` indicates the debug action has started successfully. + /// For example, `โ€Œsucceeded` often does not indicate success as if the action fails, + /// it will sometimes return `โ€Œsucceeded`. + final String? status; + + /// When [status] is `โ€Œerror occurred`, an error message is provided. + /// Otherwise, this will be null. + final String? errorMessage; +} + +class XcodeDebugProject { + XcodeDebugProject({ + required this.scheme, + required this.xcodeWorkspace, + required this.xcodeProject, + this.isTemporaryProject = false, + this.verboseLogging = false, + }); + + final String scheme; + final Directory xcodeWorkspace; + final Directory xcodeProject; + final bool isTemporaryProject; + + /// When [verboseLogging] is true, the xcode_debug.js script will log + /// additional information via console.log, which is sent to stderr. + final bool verboseLogging; +} diff --git a/packages/flutter_tools/lib/src/macos/xcdevice.dart b/packages/flutter_tools/lib/src/macos/xcdevice.dart index c53fdcaf9260b..a2c66eea776d1 100644 --- a/packages/flutter_tools/lib/src/macos/xcdevice.dart +++ b/packages/flutter_tools/lib/src/macos/xcdevice.dart @@ -8,6 +8,7 @@ import 'package:meta/meta.dart'; import 'package:process/process.dart'; import '../artifacts.dart'; +import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; import '../base/platform.dart'; @@ -18,10 +19,12 @@ import '../cache.dart'; import '../convert.dart'; import '../device.dart'; import '../globals.dart' as globals; +import '../ios/core_devices.dart'; import '../ios/devices.dart'; import '../ios/ios_deploy.dart'; import '../ios/iproxy.dart'; import '../ios/mac.dart'; +import '../ios/xcode_debug.dart'; import '../reporting/reporting.dart'; import 'xcode.dart'; @@ -65,6 +68,10 @@ class XCDevice { required Xcode xcode, required Platform platform, required IProxy iproxy, + required FileSystem fileSystem, + @visibleForTesting + IOSCoreDeviceControl? coreDeviceControl, + XcodeDebug? xcodeDebug, }) : _processUtils = ProcessUtils(logger: logger, processManager: processManager), _logger = logger, _iMobileDevice = IMobileDevice( @@ -80,6 +87,18 @@ class XCDevice { platform: platform, processManager: processManager, ), + _coreDeviceControl = coreDeviceControl ?? IOSCoreDeviceControl( + logger: logger, + processManager: processManager, + xcode: xcode, + fileSystem: fileSystem, + ), + _xcodeDebug = xcodeDebug ?? XcodeDebug( + logger: logger, + processManager: processManager, + xcode: xcode, + fileSystem: fileSystem, + ), _iProxy = iproxy, _xcode = xcode { @@ -99,6 +118,8 @@ class XCDevice { final IOSDeploy _iosDeploy; final Xcode _xcode; final IProxy _iProxy; + final IOSCoreDeviceControl _coreDeviceControl; + final XcodeDebug _xcodeDebug; List? _cachedListResults; @@ -457,6 +478,17 @@ class XCDevice { return const []; } + final Map coreDeviceMap = {}; + if (_xcode.isDevicectlInstalled) { + final List coreDevices = await _coreDeviceControl.getCoreDevices(); + for (final IOSCoreDevice device in coreDevices) { + if (device.udid == null) { + continue; + } + coreDeviceMap[device.udid!] = device; + } + } + // [ // { // "simulator" : true, @@ -565,11 +597,27 @@ class XCDevice { } } + DeviceConnectionInterface connectionInterface = _interfaceType(device); + + // CoreDevices (devices with iOS 17 and greater) no longer reflect the + // correct connection interface or developer mode status in `xcdevice`. + // Use `devicectl` to get that information for CoreDevices. + final IOSCoreDevice? coreDevice = coreDeviceMap[identifier]; + if (coreDevice != null) { + if (coreDevice.connectionInterface != null) { + connectionInterface = coreDevice.connectionInterface!; + } + + if (coreDevice.deviceProperties?.developerModeStatus != 'enabled') { + devModeEnabled = false; + } + } + deviceMap[identifier] = IOSDevice( identifier, name: name, cpuArchitecture: _cpuArchitecture(device), - connectionInterface: _interfaceType(device), + connectionInterface: connectionInterface, isConnected: isConnected, sdkVersion: sdkVersionString, iProxy: _iProxy, @@ -577,8 +625,11 @@ class XCDevice { logger: _logger, iosDeploy: _iosDeploy, iMobileDevice: _iMobileDevice, + coreDeviceControl: _coreDeviceControl, + xcodeDebug: _xcodeDebug, platform: globals.platform, devModeEnabled: devModeEnabled, + isCoreDevice: coreDevice != null, ); } } diff --git a/packages/flutter_tools/lib/src/macos/xcode.dart b/packages/flutter_tools/lib/src/macos/xcode.dart index 4784013da8e70..540f08d5f3b38 100644 --- a/packages/flutter_tools/lib/src/macos/xcode.dart +++ b/packages/flutter_tools/lib/src/macos/xcode.dart @@ -14,8 +14,10 @@ import '../base/io.dart'; import '../base/logger.dart'; import '../base/platform.dart'; import '../base/process.dart'; +import '../base/user_messages.dart'; import '../base/version.dart'; import '../build_info.dart'; +import '../cache.dart'; import '../ios/xcodeproj.dart'; Version get xcodeRequiredVersion => Version(14, null, null); @@ -44,9 +46,13 @@ class Xcode { required Logger logger, required FileSystem fileSystem, required XcodeProjectInterpreter xcodeProjectInterpreter, + required UserMessages userMessages, + String? flutterRoot, }) : _platform = platform, _fileSystem = fileSystem, _xcodeProjectInterpreter = xcodeProjectInterpreter, + _userMessage = userMessages, + _flutterRoot = flutterRoot, _processUtils = ProcessUtils(logger: logger, processManager: processManager), _logger = logger; @@ -61,6 +67,7 @@ class Xcode { XcodeProjectInterpreter? xcodeProjectInterpreter, Platform? platform, FileSystem? fileSystem, + String? flutterRoot, Logger? logger, }) { platform ??= FakePlatform( @@ -72,6 +79,8 @@ class Xcode { platform: platform, processManager: processManager, fileSystem: fileSystem ?? MemoryFileSystem.test(), + userMessages: UserMessages(), + flutterRoot: flutterRoot, logger: logger, xcodeProjectInterpreter: xcodeProjectInterpreter ?? XcodeProjectInterpreter.test(processManager: processManager), ); @@ -81,6 +90,8 @@ class Xcode { final ProcessUtils _processUtils; final FileSystem _fileSystem; final XcodeProjectInterpreter _xcodeProjectInterpreter; + final UserMessages _userMessage; + final String? _flutterRoot; final Logger _logger; bool get isInstalledAndMeetsVersionCheck => _platform.isMacOS && isInstalled && isRequiredVersionSatisfactory; @@ -101,6 +112,38 @@ class Xcode { return _xcodeSelectPath; } + String get xcodeAppPath { + // If the Xcode Select Path is /Applications/Xcode.app/Contents/Developer, + // the path to Xcode App is /Applications/Xcode.app + + final String? pathToXcode = xcodeSelectPath; + if (pathToXcode == null || pathToXcode.isEmpty) { + throwToolExit(_userMessage.xcodeMissing); + } + final int index = pathToXcode.indexOf('.app'); + if (index == -1) { + throwToolExit(_userMessage.xcodeMissing); + } + return pathToXcode.substring(0, index + 4); + } + + /// Path to script to automate debugging through Xcode. Used in xcode_debug.dart. + /// Located in this file to make it easily overrideable in google3. + String get xcodeAutomationScriptPath { + final String flutterRoot = _flutterRoot ?? Cache.flutterRoot!; + final String flutterToolsAbsolutePath = _fileSystem.path.join( + flutterRoot, + 'packages', + 'flutter_tools', + ); + + final String filePath = '$flutterToolsAbsolutePath/bin/xcode_debug.js'; + if (!_fileSystem.file(filePath).existsSync()) { + throwToolExit('Unable to find Xcode automation script at $filePath'); + } + return filePath; + } + bool get isInstalled => _xcodeProjectInterpreter.isInstalled; Version? get currentVersion => _xcodeProjectInterpreter.version; @@ -150,6 +193,28 @@ class Xcode { return _isSimctlInstalled ?? false; } + bool? _isDevicectlInstalled; + + /// Verifies that `devicectl` is installed by checking Xcode version and trying + /// to run it. `devicectl` is made available in Xcode 15. + bool get isDevicectlInstalled { + if (_isDevicectlInstalled == null) { + try { + if (currentVersion == null || currentVersion!.major < 15) { + _isDevicectlInstalled = false; + return _isDevicectlInstalled!; + } + final RunResult result = _processUtils.runSync( + [...xcrunCommand(), 'devicectl', '--version'], + ); + _isDevicectlInstalled = result.exitCode == 0; + } on ProcessException { + _isDevicectlInstalled = false; + } + } + return _isDevicectlInstalled ?? false; + } + bool get isRequiredVersionSatisfactory { final Version? version = currentVersion; if (version == null) { diff --git a/packages/flutter_tools/lib/src/mdns_discovery.dart b/packages/flutter_tools/lib/src/mdns_discovery.dart index 82196118afc00..da6527596caa3 100644 --- a/packages/flutter_tools/lib/src/mdns_discovery.dart +++ b/packages/flutter_tools/lib/src/mdns_discovery.dart @@ -130,9 +130,9 @@ class MDnsVmServiceDiscovery { /// The [deviceVmservicePort] parameter must be set to specify which port /// to find. /// - /// [applicationId] and [deviceVmservicePort] are required for launch so that - /// if multiple flutter apps are running on different devices, it will - /// only match with the device running the desired app. + /// [applicationId] and either [deviceVmservicePort] or [deviceName] are + /// required for launch so that if multiple flutter apps are running on + /// different devices, it will only match with the device running the desired app. /// /// The [useDeviceIPAsHost] parameter flags whether to get the device IP /// and the [ipv6] parameter flags whether to get an iPv6 address @@ -141,21 +141,27 @@ class MDnsVmServiceDiscovery { /// The [timeout] parameter determines how long to continue to wait for /// services to become active. /// - /// If a Dart VM Service matching the [applicationId] and [deviceVmservicePort] - /// cannot be found after the [timeout], it will call [throwToolExit]. + /// If a Dart VM Service matching the [applicationId] and + /// [deviceVmservicePort]/[deviceName] cannot be found before the [timeout] + /// is reached, it will call [throwToolExit]. @visibleForTesting Future queryForLaunch({ required String applicationId, - required int deviceVmservicePort, + int? deviceVmservicePort, + String? deviceName, bool ipv6 = false, bool useDeviceIPAsHost = false, Duration timeout = const Duration(minutes: 10), }) async { - // Query for a specific application and device port. + // Either the device port or the device name must be provided. + assert(deviceVmservicePort != null || deviceName != null); + + // Query for a specific application matching on either device port or device name. return firstMatchingVmService( _client, applicationId: applicationId, deviceVmservicePort: deviceVmservicePort, + deviceName: deviceName, ipv6: ipv6, useDeviceIPAsHost: useDeviceIPAsHost, timeout: timeout, @@ -170,6 +176,7 @@ class MDnsVmServiceDiscovery { MDnsClient client, { String? applicationId, int? deviceVmservicePort, + String? deviceName, bool ipv6 = false, bool useDeviceIPAsHost = false, Duration timeout = const Duration(minutes: 10), @@ -178,6 +185,7 @@ class MDnsVmServiceDiscovery { client, applicationId: applicationId, deviceVmservicePort: deviceVmservicePort, + deviceName: deviceName, ipv6: ipv6, useDeviceIPAsHost: useDeviceIPAsHost, timeout: timeout, @@ -193,6 +201,7 @@ class MDnsVmServiceDiscovery { MDnsClient client, { String? applicationId, int? deviceVmservicePort, + String? deviceName, bool ipv6 = false, bool useDeviceIPAsHost = false, required Duration timeout, @@ -263,6 +272,11 @@ class MDnsVmServiceDiscovery { continue; } + // If deviceName is set, only use records that match it + if (deviceName != null && !deviceNameMatchesTargetName(deviceName, srvRecord.target)) { + continue; + } + // Get the IP address of the device if using the IP as the host. InternetAddress? ipAddress; if (useDeviceIPAsHost) { @@ -332,6 +346,15 @@ class MDnsVmServiceDiscovery { } } + @visibleForTesting + bool deviceNameMatchesTargetName(String deviceName, String targetName) { + // Remove `.local` from the name along with any non-word, non-digit characters. + final RegExp cleanedNameRegex = RegExp(r'\.local|\W'); + final String cleanedDeviceName = deviceName.trim().toLowerCase().replaceAll(cleanedNameRegex, ''); + final String cleanedTargetName = targetName.toLowerCase().replaceAll(cleanedNameRegex, ''); + return cleanedDeviceName == cleanedTargetName; + } + String _getAuthCode(String txtRecord) { const String authCodePrefix = 'authCode='; final Iterable matchingRecords = @@ -354,7 +377,7 @@ class MDnsVmServiceDiscovery { /// When [useDeviceIPAsHost] is true, it will use the device's IP as the /// host and will not forward the port. /// - /// Differs from `getVMServiceUriForLaunch` because it can search for any available Dart VM Service. + /// Differs from [getVMServiceUriForLaunch] because it can search for any available Dart VM Service. /// Since [applicationId] and [deviceVmservicePort] are optional, it can either look for any service /// or a specific service matching [applicationId]/[deviceVmservicePort]. /// It may find more than one service, which will throw an error listing the found services. @@ -391,20 +414,22 @@ class MDnsVmServiceDiscovery { /// When [useDeviceIPAsHost] is true, it will use the device's IP as the /// host and will not forward the port. /// - /// Differs from `getVMServiceUriForAttach` because it only searches for a specific service. - /// This is enforced by [applicationId] and [deviceVmservicePort] being required. + /// Differs from [getVMServiceUriForAttach] because it only searches for a specific service. + /// This is enforced by [applicationId] being required and using either the + /// [deviceVmservicePort] or the [device]'s name to query. Future getVMServiceUriForLaunch( String applicationId, Device device, { bool usesIpv6 = false, int? hostVmservicePort, - required int deviceVmservicePort, + int? deviceVmservicePort, bool useDeviceIPAsHost = false, Duration timeout = const Duration(minutes: 10), }) async { final MDnsVmServiceDiscoveryResult? result = await queryForLaunch( applicationId: applicationId, deviceVmservicePort: deviceVmservicePort, + deviceName: deviceVmservicePort == null ? device.name : null, ipv6: usesIpv6, useDeviceIPAsHost: useDeviceIPAsHost, timeout: timeout, diff --git a/packages/flutter_tools/templates/template_manifest.json b/packages/flutter_tools/templates/template_manifest.json index d0902d5edc286..3729d8909fa75 100644 --- a/packages/flutter_tools/templates/template_manifest.json +++ b/packages/flutter_tools/templates/template_manifest.json @@ -339,6 +339,15 @@ "templates/skeleton/README.md.tmpl", "templates/skeleton/test/implementation_test.dart.test.tmpl", "templates/skeleton/test/unit_test.dart.tmpl", - "templates/skeleton/test/widget_test.dart.tmpl" + "templates/skeleton/test/widget_test.dart.tmpl", + + "templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/contents.xcworkspacedata", + "templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/xcshareddata/IDEWorkspaceChecks.plist", + "templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/xcshareddata/WorkspaceSettings.xcsettings", + "templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.pbxproj", + "templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/contents.xcworkspacedata", + "templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist", + "templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings", + "templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/xcshareddata/xcschemes/Runner.xcscheme.tmpl" ] } diff --git a/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/README.md b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/README.md new file mode 100644 index 0000000000000..2b2b69707db5d --- /dev/null +++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/README.md @@ -0,0 +1,5 @@ +# Template Xcode project with a custom application bundle + +This template is an empty Xcode project with a settable application bundle path +within the `xcscheme`. It is used when debugging a project on a physical iOS 17+ +device via Xcode 15+ when `--use-application-binary` is used. \ No newline at end of file diff --git a/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.pbxproj b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.pbxproj new file mode 100644 index 0000000000000..8f544ef77eb2b --- /dev/null +++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.pbxproj @@ -0,0 +1,297 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXFileReference section */ + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 97C146EF1CF9000F007C117D /* Products */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/contents.xcworkspacedata b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000000..919434a6254f0 --- /dev/null +++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000000..18d981003d68d --- /dev/null +++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000000..f9b0d7c5ea15f --- /dev/null +++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/xcshareddata/xcschemes/Runner.xcscheme.tmpl b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/xcshareddata/xcschemes/Runner.xcscheme.tmpl new file mode 100644 index 0000000000000..bcca935dea1c6 --- /dev/null +++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/xcshareddata/xcschemes/Runner.xcscheme.tmpl @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/contents.xcworkspacedata b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/contents.xcworkspacedata new file mode 100644 index 0000000000000..1d526a16ed0f1 --- /dev/null +++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/xcshareddata/IDEWorkspaceChecks.plist b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000000..18d981003d68d --- /dev/null +++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/xcshareddata/WorkspaceSettings.xcsettings b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000000..f9b0d7c5ea15f --- /dev/null +++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/flutter_tools/test/general.shard/device_test.dart b/packages/flutter_tools/test/general.shard/device_test.dart index 2774536308214..860a58ff687ca 100644 --- a/packages/flutter_tools/test/general.shard/device_test.dart +++ b/packages/flutter_tools/test/general.shard/device_test.dart @@ -885,6 +885,26 @@ void main() { ); }); + testWithoutContext('Get launch arguments for physical CoreDevice with debugging enabled with no launch arguments', () { + final DebuggingOptions original = DebuggingOptions.enabled( + BuildInfo.debug, + ); + + final List launchArguments = original.getIOSLaunchArguments( + EnvironmentType.physical, + null, + {}, + isCoreDevice: true, + ); + + expect( + launchArguments.join(' '), + [ + '--enable-dart-profiling', + ].join(' '), + ); + }); + testWithoutContext('Get launch arguments for physical device with iPv4 network connection', () { final DebuggingOptions original = DebuggingOptions.enabled( BuildInfo.debug, diff --git a/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart b/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart new file mode 100644 index 0000000000000..d820e1a13bcb2 --- /dev/null +++ b/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart @@ -0,0 +1,1949 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:file/memory.dart'; +import 'package:file_testing/file_testing.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/version.dart'; +import 'package:flutter_tools/src/ios/core_devices.dart'; +import 'package:flutter_tools/src/ios/xcodeproj.dart'; +import 'package:flutter_tools/src/macos/xcode.dart'; + +import '../../src/common.dart'; +import '../../src/fake_process_manager.dart'; + +void main() { + late MemoryFileSystem fileSystem; + + setUp(() { + fileSystem = MemoryFileSystem.test(); + }); + + group('Xcode prior to Core Device Control/Xcode 15', () { + late BufferLogger logger; + late FakeProcessManager fakeProcessManager; + late Xcode xcode; + late IOSCoreDeviceControl deviceControl; + + setUp(() { + logger = BufferLogger.test(); + fakeProcessManager = FakeProcessManager.empty(); + final XcodeProjectInterpreter xcodeProjectInterpreter = XcodeProjectInterpreter.test( + processManager: fakeProcessManager, + version: Version(14, 0, 0), + ); + xcode = Xcode.test( + processManager: FakeProcessManager.any(), + xcodeProjectInterpreter: xcodeProjectInterpreter, + ); + deviceControl = IOSCoreDeviceControl( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + }); + + group('devicectl is not installed', () { + testWithoutContext('fails to get device list', () async { + final List devices = await deviceControl.getCoreDevices(); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl is not installed.')); + expect(devices.isEmpty, isTrue); + }); + + testWithoutContext('fails to install app', () async { + final bool status = await deviceControl.installApp(deviceId: 'device-id', bundlePath: '/path/to/bundle'); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl is not installed.')); + expect(status, isFalse); + }); + + testWithoutContext('fails to launch app', () async { + final bool status = await deviceControl.launchApp(deviceId: 'device-id', bundleId: 'com.example.flutterApp'); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl is not installed.')); + expect(status, isFalse); + }); + + testWithoutContext('fails to check if app is installed', () async { + final bool status = await deviceControl.isAppInstalled(deviceId: 'device-id', bundleId: 'com.example.flutterApp'); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl is not installed.')); + expect(status, isFalse); + }); + }); + }); + + group('Core Device Control', () { + late BufferLogger logger; + late FakeProcessManager fakeProcessManager; + late Xcode xcode; + late IOSCoreDeviceControl deviceControl; + + setUp(() { + logger = BufferLogger.test(); + fakeProcessManager = FakeProcessManager.empty(); + xcode = Xcode.test(processManager: FakeProcessManager.any()); + deviceControl = IOSCoreDeviceControl( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + }); + + group('install app', () { + const String deviceId = 'device-id'; + const String bundlePath = '/path/to/com.example.flutterApp'; + + testWithoutContext('Successful install', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "device", + "install", + "app", + "--device", + "00001234-0001234A3C03401E", + "build/ios/iphoneos/Runner.app", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + ], + "commandType" : "devicectl.device.install.app", + "environment" : { + + }, + "outcome" : "success", + "version" : "341" + }, + "result" : { + "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "installedApplications" : [ + { + "bundleID" : "com.example.bundle", + "databaseSequenceNumber" : 1230, + "databaseUUID" : "1234A567-D890-1B23-BCF4-D5D67A8D901E", + "installationURL" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/", + "launchServicesIdentifier" : "unknown", + "options" : { + + } + } + ] + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('install_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'install', + 'app', + '--device', + deviceId, + bundlePath, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.installApp( + deviceId: deviceId, + bundlePath: bundlePath, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(tempFile, isNot(exists)); + expect(status, true); + }); + + testWithoutContext('devicectl fails install', () async { + const String deviceControlOutput = ''' +{ + "error" : { + "code" : 1005, + "domain" : "com.apple.dt.CoreDeviceError", + "userInfo" : { + "NSLocalizedDescription" : { + "string" : "Could not obtain access to one or more requested file system resources because CoreDevice was unable to create bookmark data." + }, + "NSUnderlyingError" : { + "error" : { + "code" : 260, + "domain" : "NSCocoaErrorDomain", + "userInfo" : { + + } + } + } + } + }, + "info" : { + "arguments" : [ + "devicectl", + "device", + "install", + "app", + "--device", + "00001234-0001234A3C03401E", + "/path/to/app", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + ], + "commandType" : "devicectl.device.install.app", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "failed", + "version" : "341" + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('install_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'install', + 'app', + '--device', + deviceId, + bundlePath, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + exitCode: 1, + stderr: ''' +ERROR: Could not obtain access to one or more requested file system resources because CoreDevice was unable to create bookmark data. (com.apple.dt.CoreDeviceError error 1005.) + NSURL = file:///path/to/app +-------------------------------------------------------------------------------- +ERROR: The file couldnโ€™t be opened because it doesnโ€™t exist. (NSCocoaErrorDomain error 260.) +''' + )); + + final bool status = await deviceControl.installApp( + deviceId: deviceId, + bundlePath: bundlePath, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('ERROR: Could not obtain access to one or more requested file system')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('fails install because of unexpected JSON', () async { + const String deviceControlOutput = ''' +{ + "valid_unexpected_json": true +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('install_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'install', + 'app', + '--device', + deviceId, + bundlePath, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.installApp( + deviceId: deviceId, + bundlePath: bundlePath, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned unexpected JSON response')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('fails install because of invalid JSON', () async { + const String deviceControlOutput = ''' +invalid JSON +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('install_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'install', + 'app', + '--device', + deviceId, + bundlePath, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.installApp( + deviceId: deviceId, + bundlePath: bundlePath, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned non-JSON response')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + }); + + group('uninstall app', () { + const String deviceId = 'device-id'; + const String bundleId = 'com.example.flutterApp'; + + testWithoutContext('Successful uninstall', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "device", + "uninstall", + "app", + "--device", + "00001234-0001234A3C03401E", + "build/ios/iphoneos/Runner.app", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/uninstall_results.json" + ], + "commandType" : "devicectl.device.uninstall.app", + "environment" : { + + }, + "outcome" : "success", + "version" : "341" + }, + "result" : { + "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "uninstalledApplications" : [ + { + "bundleID" : "com.example.bundle" + } + ] + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('uninstall_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'uninstall', + 'app', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.uninstallApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(tempFile, isNot(exists)); + expect(status, true); + }); + + testWithoutContext('devicectl fails uninstall', () async { + const String deviceControlOutput = ''' +{ + "error" : { + "code" : 1005, + "domain" : "com.apple.dt.CoreDeviceError", + "userInfo" : { + "NSLocalizedDescription" : { + "string" : "Could not obtain access to one or more requested file system resources because CoreDevice was unable to create bookmark data." + }, + "NSUnderlyingError" : { + "error" : { + "code" : 260, + "domain" : "NSCocoaErrorDomain", + "userInfo" : { + + } + } + } + } + }, + "info" : { + "arguments" : [ + "devicectl", + "device", + "uninstall", + "app", + "--device", + "00001234-0001234A3C03401E", + "com.example.flutterApp", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/uninstall_results.json" + ], + "commandType" : "devicectl.device.uninstall.app", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "failed", + "version" : "341" + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('uninstall_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'uninstall', + 'app', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + exitCode: 1, + stderr: ''' +ERROR: Could not obtain access to one or more requested file system resources because CoreDevice was unable to create bookmark data. (com.apple.dt.CoreDeviceError error 1005.) + NSURL = file:///path/to/app +-------------------------------------------------------------------------------- +ERROR: The file couldnโ€™t be opened because it doesnโ€™t exist. (NSCocoaErrorDomain error 260.) +''' + )); + + final bool status = await deviceControl.uninstallApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('ERROR: Could not obtain access to one or more requested file system')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('fails uninstall because of unexpected JSON', () async { + const String deviceControlOutput = ''' +{ + "valid_unexpected_json": true +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('uninstall_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'uninstall', + 'app', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.uninstallApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned unexpected JSON response')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('fails uninstall because of invalid JSON', () async { + const String deviceControlOutput = ''' +invalid JSON +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('uninstall_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'uninstall', + 'app', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.uninstallApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned non-JSON response')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + }); + + group('launch app', () { + const String deviceId = 'device-id'; + const String bundleId = 'com.example.flutterApp'; + + testWithoutContext('Successful launch without launch args', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "device", + "process", + "launch", + "--device", + "00001234-0001234A3C03401E", + "com.example.flutterApp", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + ], + "commandType" : "devicectl.device.process.launch", + "environment" : { + + }, + "outcome" : "success", + "version" : "341" + }, + "result" : { + "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "launchOptions" : { + "activatedWhenStarted" : true, + "arguments" : [ + + ], + "environmentVariables" : { + "TERM" : "vt100" + }, + "platformSpecificOptions" : { + + }, + "startStopped" : false, + "terminateExistingInstances" : false, + "user" : { + "active" : true + } + }, + "process" : { + "auditToken" : [ + 12345, + 678 + ], + "executable" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/Runner", + "processIdentifier" : 1234 + } + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.launchApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(tempFile, isNot(exists)); + expect(status, true); + }); + + testWithoutContext('Successful launch with launch args', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "device", + "process", + "launch", + "--device", + "00001234-0001234A3C03401E", + "com.example.flutterApp", + "--arg1", + "--arg2", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + ], + "commandType" : "devicectl.device.process.launch", + "environment" : { + + }, + "outcome" : "success", + "version" : "341" + }, + "result" : { + "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "launchOptions" : { + "activatedWhenStarted" : true, + "arguments" : [ + + ], + "environmentVariables" : { + "TERM" : "vt100" + }, + "platformSpecificOptions" : { + + }, + "startStopped" : false, + "terminateExistingInstances" : false, + "user" : { + "active" : true + } + }, + "process" : { + "auditToken" : [ + 12345, + 678 + ], + "executable" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/Runner", + "processIdentifier" : 1234 + } + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + '--arg1', + '--arg2', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.launchApp( + deviceId: deviceId, + bundleId: bundleId, + launchArguments: ['--arg1', '--arg2'], + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(tempFile, isNot(exists)); + expect(status, true); + }); + + testWithoutContext('devicectl fails install', () async { + const String deviceControlOutput = ''' +{ + "error" : { + "code" : -10814, + "domain" : "NSOSStatusErrorDomain", + "userInfo" : { + "_LSFunction" : { + "string" : "runEvaluator" + }, + "_LSLine" : { + "int" : 1608 + } + } + }, + "info" : { + "arguments" : [ + "devicectl", + "device", + "process", + "launch", + "--device", + "00001234-0001234A3C03401E", + "com.example.flutterApp", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + ], + "commandType" : "devicectl.device.process.launch", + "environment" : { + + }, + "outcome" : "failed", + "version" : "341" + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + exitCode: 1, + stderr: ''' +ERROR: The operation couldn?t be completed. (OSStatus error -10814.) (NSOSStatusErrorDomain error -10814.) + _LSFunction = runEvaluator + _LSLine = 1608 +''' + )); + + final bool status = await deviceControl.launchApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('ERROR: The operation couldn?t be completed.')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('fails launch because of unexpected JSON', () async { + const String deviceControlOutput = ''' +{ + "valid_unexpected_json": true +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + + final bool status = await deviceControl.launchApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned unexpected JSON response')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('fails launch because of invalid JSON', () async { + const String deviceControlOutput = ''' +invalid JSON +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.launchApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned non-JSON response')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + }); + + group('list apps', () { + const String deviceId = 'device-id'; + const String bundleId = 'com.example.flutterApp'; + + testWithoutContext('Successfully parses apps', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "device", + "info", + "apps", + "--device", + "00001234-0001234A3C03401E", + "--bundle-id", + "com.example.flutterApp", + "--json-output", + "apps.txt" + ], + "commandType" : "devicectl.device.info.apps", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "success", + "version" : "341" + }, + "result" : { + "apps" : [ + { + "appClip" : false, + "builtByDeveloper" : true, + "bundleIdentifier" : "com.example.flutterApp", + "bundleVersion" : "1", + "defaultApp" : false, + "hidden" : false, + "internalApp" : false, + "name" : "Bundle", + "removable" : true, + "url" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/", + "version" : "1.0.0" + }, + { + "appClip" : true, + "builtByDeveloper" : false, + "bundleIdentifier" : "com.example.flutterApp2", + "bundleVersion" : "2", + "defaultApp" : true, + "hidden" : true, + "internalApp" : true, + "name" : "Bundle 2", + "removable" : false, + "url" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/", + "version" : "1.0.0" + } + ], + "defaultAppsIncluded" : false, + "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "hiddenAppsIncluded" : false, + "internalAppsIncluded" : false, + "matchingBundleIdentifier" : "com.example.flutterApp", + "removableAppsIncluded" : true + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_app_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'info', + 'apps', + '--device', + deviceId, + '--bundle-id', + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List apps = await deviceControl.getInstalledApps( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(tempFile, isNot(exists)); + expect(apps.length, 2); + + expect(apps[0].appClip, isFalse); + expect(apps[0].builtByDeveloper, isTrue); + expect(apps[0].bundleIdentifier, 'com.example.flutterApp'); + expect(apps[0].bundleVersion, '1'); + expect(apps[0].defaultApp, isFalse); + expect(apps[0].hidden, isFalse); + expect(apps[0].internalApp, isFalse); + expect(apps[0].name, 'Bundle'); + expect(apps[0].removable, isTrue); + expect(apps[0].url, 'file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/'); + expect(apps[0].version, '1.0.0'); + + expect(apps[1].appClip, isTrue); + expect(apps[1].builtByDeveloper, isFalse); + expect(apps[1].bundleIdentifier, 'com.example.flutterApp2'); + expect(apps[1].bundleVersion, '2'); + expect(apps[1].defaultApp, isTrue); + expect(apps[1].hidden, isTrue); + expect(apps[1].internalApp, isTrue); + expect(apps[1].name, 'Bundle 2'); + expect(apps[1].removable, isFalse); + expect(apps[1].url, 'file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/'); + expect(apps[1].version, '1.0.0'); + }); + + + testWithoutContext('Successfully find installed app', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "device", + "info", + "apps", + "--device", + "00001234-0001234A3C03401E", + "--bundle-id", + "com.example.flutterApp", + "--json-output", + "apps.txt" + ], + "commandType" : "devicectl.device.info.apps", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "success", + "version" : "341" + }, + "result" : { + "apps" : [ + { + "appClip" : false, + "builtByDeveloper" : true, + "bundleIdentifier" : "com.example.flutterApp", + "bundleVersion" : "1", + "defaultApp" : false, + "hidden" : false, + "internalApp" : false, + "name" : "Bundle", + "removable" : true, + "url" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/", + "version" : "1.0.0" + } + ], + "defaultAppsIncluded" : false, + "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "hiddenAppsIncluded" : false, + "internalAppsIncluded" : false, + "matchingBundleIdentifier" : "com.example.flutterApp", + "removableAppsIncluded" : true + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_app_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'info', + 'apps', + '--device', + deviceId, + '--bundle-id', + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.isAppInstalled( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(tempFile, isNot(exists)); + expect(status, true); + }); + + testWithoutContext('Succeeds but does not find app', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "device", + "info", + "apps", + "--device", + "00001234-0001234A3C03401E", + "--bundle-id", + "com.example.flutterApp", + "--json-output", + "apps.txt" + ], + "commandType" : "devicectl.device.info.apps", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "success", + "version" : "341" + }, + "result" : { + "apps" : [ + ], + "defaultAppsIncluded" : false, + "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "hiddenAppsIncluded" : false, + "internalAppsIncluded" : false, + "matchingBundleIdentifier" : "com.example.flutterApp", + "removableAppsIncluded" : true + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_app_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'info', + 'apps', + '--device', + deviceId, + '--bundle-id', + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.isAppInstalled( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('devicectl fails to get apps', () async { + const String deviceControlOutput = ''' +{ + "error" : { + "code" : 1000, + "domain" : "com.apple.dt.CoreDeviceError", + "userInfo" : { + "NSLocalizedDescription" : { + "string" : "The specified device was not found." + } + } + }, + "info" : { + "arguments" : [ + "devicectl", + "device", + "info", + "apps", + "--device", + "00001234-0001234A3C03401E", + "--bundle-id", + "com.example.flutterApp", + "--json-output", + "apps.txt" + ], + "commandType" : "devicectl.device.info.apps", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "failed", + "version" : "341" + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_app_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'info', + 'apps', + '--device', + deviceId, + '--bundle-id', + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + exitCode: 1, + stderr: ''' +ERROR: The specified device was not found. (com.apple.dt.CoreDeviceError error 1000.) +''' + )); + + final bool status = await deviceControl.isAppInstalled( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('ERROR: The specified device was not found.')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('fails launch because of unexpected JSON', () async { + const String deviceControlOutput = ''' +{ + "valid_unexpected_json": true +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_app_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'info', + 'apps', + '--device', + deviceId, + '--bundle-id', + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + + final bool status = await deviceControl.isAppInstalled( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned unexpected JSON response')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('fails launch because of invalid JSON', () async { + const String deviceControlOutput = ''' +invalid JSON +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_app_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'info', + 'apps', + '--device', + deviceId, + '--bundle-id', + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.isAppInstalled( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned non-JSON response')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + }); + + group('list devices', () { + testWithoutContext('No devices', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "list", + "devices", + "--json-output", + "core_device_list.json" + ], + "commandType" : "devicectl.list.devices", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "success", + "version" : "325.3" + }, + "result" : { + "devices" : [ + + ] + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--timeout', + '5', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List devices = await deviceControl.getCoreDevices(); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(devices.isEmpty, isTrue); + }); + + testWithoutContext('All sections parsed', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "list", + "devices", + "--json-output", + "core_device_list.json" + ], + "commandType" : "devicectl.list.devices", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "success", + "version" : "325.3" + }, + "result" : { + "devices" : [ + { + "capabilities" : [ + ], + "connectionProperties" : { + }, + "deviceProperties" : { + }, + "hardwareProperties" : { + }, + "identifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "visibilityClass" : "default" + } + ] + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--timeout', + '5', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List devices = await deviceControl.getCoreDevices(); + expect(devices.length, 1); + + expect(devices[0].capabilities, isNotNull); + expect(devices[0].connectionProperties, isNotNull); + expect(devices[0].deviceProperties, isNotNull); + expect(devices[0].hardwareProperties, isNotNull); + expect(devices[0].coreDeviceIdentifer, '123456BB5-AEDE-7A22-B890-1234567890DD'); + expect(devices[0].visibilityClass, 'default'); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(tempFile, isNot(exists)); + }); + + testWithoutContext('All sections parsed, device missing sections', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "list", + "devices", + "--json-output", + "core_device_list.json" + ], + "commandType" : "devicectl.list.devices", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "success", + "version" : "325.3" + }, + "result" : { + "devices" : [ + { + "identifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "visibilityClass" : "default" + } + ] + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--timeout', + '5', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List devices = await deviceControl.getCoreDevices(); + expect(devices.length, 1); + + expect(devices[0].capabilities, isEmpty); + expect(devices[0].connectionProperties, isNull); + expect(devices[0].deviceProperties, isNull); + expect(devices[0].hardwareProperties, isNull); + expect(devices[0].coreDeviceIdentifer, '123456BB5-AEDE-7A22-B890-1234567890DD'); + expect(devices[0].visibilityClass, 'default'); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(tempFile, isNot(exists)); + }); + + testWithoutContext('capabilities parsed', () async { + const String deviceControlOutput = ''' +{ + "result" : { + "devices" : [ + { + "capabilities" : [ + { + "featureIdentifier" : "com.apple.coredevice.feature.spawnexecutable", + "name" : "Spawn Executable" + }, + { + "featureIdentifier" : "com.apple.coredevice.feature.launchapplication", + "name" : "Launch Application" + } + ] + } + ] + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--timeout', + '5', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List devices = await deviceControl.getCoreDevices(); + expect(devices.length, 1); + + expect(devices[0].capabilities.length, 2); + expect(devices[0].capabilities[0].featureIdentifier, 'com.apple.coredevice.feature.spawnexecutable'); + expect(devices[0].capabilities[0].name, 'Spawn Executable'); + expect(devices[0].capabilities[1].featureIdentifier, 'com.apple.coredevice.feature.launchapplication'); + expect(devices[0].capabilities[1].name, 'Launch Application'); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(tempFile, isNot(exists)); + }); + + testWithoutContext('connectionProperties parsed', () async { + const String deviceControlOutput = ''' +{ + "result" : { + "devices" : [ + { + "connectionProperties" : { + "authenticationType" : "manualPairing", + "isMobileDeviceOnly" : false, + "lastConnectionDate" : "2023-06-15T15:29:00.082Z", + "localHostnames" : [ + "Victorias-iPad.coredevice.local", + "00001234-0001234A3C03401E.coredevice.local", + "123456BB5-AEDE-7A22-B890-1234567890DD.coredevice.local" + ], + "pairingState" : "paired", + "potentialHostnames" : [ + "00001234-0001234A3C03401E.coredevice.local", + "123456BB5-AEDE-7A22-B890-1234567890DD.coredevice.local" + ], + "transportType" : "wired", + "tunnelIPAddress" : "fdf1:23c4:cd56::1", + "tunnelState" : "connected", + "tunnelTransportProtocol" : "tcp" + } + } + ] + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--timeout', + '5', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List devices = await deviceControl.getCoreDevices(); + expect(devices.length, 1); + + expect(devices[0].connectionProperties?.authenticationType, 'manualPairing'); + expect(devices[0].connectionProperties?.isMobileDeviceOnly, false); + expect(devices[0].connectionProperties?.lastConnectionDate, '2023-06-15T15:29:00.082Z'); + expect( + devices[0].connectionProperties?.localHostnames, + [ + 'Victorias-iPad.coredevice.local', + '00001234-0001234A3C03401E.coredevice.local', + '123456BB5-AEDE-7A22-B890-1234567890DD.coredevice.local', + ], + ); + expect(devices[0].connectionProperties?.pairingState, 'paired'); + expect(devices[0].connectionProperties?.potentialHostnames, [ + '00001234-0001234A3C03401E.coredevice.local', + '123456BB5-AEDE-7A22-B890-1234567890DD.coredevice.local', + ]); + expect(devices[0].connectionProperties?.transportType, 'wired'); + expect(devices[0].connectionProperties?.tunnelIPAddress, 'fdf1:23c4:cd56::1'); + expect(devices[0].connectionProperties?.tunnelState, 'connected'); + expect(devices[0].connectionProperties?.tunnelTransportProtocol, 'tcp'); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(tempFile, isNot(exists)); + }); + + testWithoutContext('deviceProperties parsed', () async { + const String deviceControlOutput = ''' +{ + "result" : { + "devices" : [ + { + "deviceProperties" : { + "bootedFromSnapshot" : true, + "bootedSnapshotName" : "com.apple.os.update-123456", + "bootState" : "booted", + "ddiServicesAvailable" : true, + "developerModeStatus" : "enabled", + "hasInternalOSBuild" : false, + "name" : "iPadName", + "osBuildUpdate" : "21A5248v", + "osVersionNumber" : "17.0", + "rootFileSystemIsWritable" : false, + "screenViewingURL" : "coredevice-devices:/viewDeviceByUUID?uuid=123456BB5-AEDE-7A22-B890-1234567890DD" + } + } + ] + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--timeout', + '5', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List devices = await deviceControl.getCoreDevices(); + expect(devices.length, 1); + + expect(devices[0].deviceProperties?.bootedFromSnapshot, true); + expect(devices[0].deviceProperties?.bootedSnapshotName, 'com.apple.os.update-123456'); + expect(devices[0].deviceProperties?.bootState, 'booted'); + expect(devices[0].deviceProperties?.ddiServicesAvailable, true); + expect(devices[0].deviceProperties?.developerModeStatus, 'enabled'); + expect(devices[0].deviceProperties?.hasInternalOSBuild, false); + expect(devices[0].deviceProperties?.name, 'iPadName'); + expect(devices[0].deviceProperties?.osBuildUpdate, '21A5248v'); + expect(devices[0].deviceProperties?.osVersionNumber, '17.0'); + expect(devices[0].deviceProperties?.rootFileSystemIsWritable, false); + expect(devices[0].deviceProperties?.screenViewingURL, 'coredevice-devices:/viewDeviceByUUID?uuid=123456BB5-AEDE-7A22-B890-1234567890DD'); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(tempFile, isNot(exists)); + }); + + testWithoutContext('hardwareProperties parsed', () async { + const String deviceControlOutput = r''' +{ + "result" : { + "devices" : [ + { + "hardwareProperties" : { + "cpuType" : { + "name" : "arm64e", + "subType" : 2, + "type" : 16777228 + }, + "deviceType" : "iPad", + "ecid" : 12345678903408542, + "hardwareModel" : "J617AP", + "internalStorageCapacity" : 128000000000, + "marketingName" : "iPad Pro (11-inch) (4th generation)\"", + "platform" : "iOS", + "productType" : "iPad14,3", + "serialNumber" : "HC123DHCQV", + "supportedCPUTypes" : [ + { + "name" : "arm64e", + "subType" : 2, + "type" : 16777228 + }, + { + "name" : "arm64", + "subType" : 0, + "type" : 16777228 + } + ], + "supportedDeviceFamilies" : [ + 1, + 2 + ], + "thinningProductType" : "iPad14,3-A", + "udid" : "00001234-0001234A3C03401E" + } + } + ] + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--timeout', + '5', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List devices = await deviceControl.getCoreDevices(); + expect(devices.length, 1); + + expect(devices[0].hardwareProperties?.cpuType, isNotNull); + expect(devices[0].hardwareProperties?.cpuType?.name, 'arm64e'); + expect(devices[0].hardwareProperties?.cpuType?.subType, 2); + expect(devices[0].hardwareProperties?.cpuType?.cpuType, 16777228); + expect(devices[0].hardwareProperties?.deviceType, 'iPad'); + expect(devices[0].hardwareProperties?.ecid, 12345678903408542); + expect(devices[0].hardwareProperties?.hardwareModel, 'J617AP'); + expect(devices[0].hardwareProperties?.internalStorageCapacity, 128000000000); + expect(devices[0].hardwareProperties?.marketingName, 'iPad Pro (11-inch) (4th generation)"'); + expect(devices[0].hardwareProperties?.platform, 'iOS'); + expect(devices[0].hardwareProperties?.productType, 'iPad14,3'); + expect(devices[0].hardwareProperties?.serialNumber, 'HC123DHCQV'); + expect(devices[0].hardwareProperties?.supportedCPUTypes, isNotNull); + expect(devices[0].hardwareProperties?.supportedCPUTypes?[0].name, 'arm64e'); + expect(devices[0].hardwareProperties?.supportedCPUTypes?[0].subType, 2); + expect(devices[0].hardwareProperties?.supportedCPUTypes?[0].cpuType, 16777228); + expect(devices[0].hardwareProperties?.supportedCPUTypes?[1].name, 'arm64'); + expect(devices[0].hardwareProperties?.supportedCPUTypes?[1].subType, 0); + expect(devices[0].hardwareProperties?.supportedCPUTypes?[1].cpuType, 16777228); + expect(devices[0].hardwareProperties?.supportedDeviceFamilies, [1, 2]); + expect(devices[0].hardwareProperties?.thinningProductType, 'iPad14,3-A'); + + expect(devices[0].hardwareProperties?.udid, '00001234-0001234A3C03401E'); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(tempFile, isNot(exists)); + }); + + group('Handles errors', () { + testWithoutContext('invalid json', () async { + const String deviceControlOutput = '''Invalid JSON'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--timeout', + '5', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List devices = await deviceControl.getCoreDevices(); + expect(devices.isEmpty, isTrue); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned non-JSON response: Invalid JSON')); + }); + + testWithoutContext('unexpected json', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "list", + "devices", + "--json-output", + "device_list.json" + ], + "commandType" : "devicectl.list.devices", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "success", + "version" : "325.3" + }, + "result" : [ + + ] +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--timeout', + '5', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List devices = await deviceControl.getCoreDevices(); + expect(devices.isEmpty, isTrue); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned unexpected JSON response:')); + }); + + testWithoutContext('When timeout is below minimum, default to minimum', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "list", + "devices", + "--json-output", + "core_device_list.json" + ], + "commandType" : "devicectl.list.devices", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "success", + "version" : "325.3" + }, + "result" : { + "devices" : [ + { + "identifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "visibilityClass" : "default" + } + ] + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--timeout', + '5', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List devices = await deviceControl.getCoreDevices( + timeout: const Duration(seconds: 2), + ); + expect(devices.isNotEmpty, isTrue); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect( + logger.errorText, + contains('Timeout of 2 seconds is below the minimum timeout value ' + 'for devicectl. Changing the timeout to the minimum value of 5.'), + ); + }); + }); + }); + + + }); +} diff --git a/packages/flutter_tools/test/general.shard/ios/devices_test.dart b/packages/flutter_tools/test/general.shard/ios/devices_test.dart index cb222d9e3fe5b..146e05b2b70b7 100644 --- a/packages/flutter_tools/test/general.shard/ios/devices_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/devices_test.dart @@ -19,11 +19,13 @@ import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/device.dart'; import 'package:flutter_tools/src/device_port_forwarder.dart'; import 'package:flutter_tools/src/ios/application_package.dart'; +import 'package:flutter_tools/src/ios/core_devices.dart'; import 'package:flutter_tools/src/ios/devices.dart'; import 'package:flutter_tools/src/ios/ios_deploy.dart'; import 'package:flutter_tools/src/ios/ios_workflow.dart'; import 'package:flutter_tools/src/ios/iproxy.dart'; import 'package:flutter_tools/src/ios/mac.dart'; +import 'package:flutter_tools/src/ios/xcode_debug.dart'; import 'package:flutter_tools/src/macos/xcdevice.dart'; import 'package:test/fake.dart'; @@ -42,6 +44,8 @@ void main() { late IOSDeploy iosDeploy; late IMobileDevice iMobileDevice; late FileSystem fileSystem; + late IOSCoreDeviceControl coreDeviceControl; + late XcodeDebug xcodeDebug; setUp(() { final Artifacts artifacts = Artifacts.test(); @@ -61,6 +65,8 @@ void main() { logger: logger, processManager: FakeProcessManager.any(), ); + coreDeviceControl = FakeIOSCoreDeviceControl(); + xcodeDebug = FakeXcodeDebug(); }); testWithoutContext('successfully instantiates on Mac OS', () { @@ -72,12 +78,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ); expect(device.isSupported(), isTrue); }); @@ -91,11 +100,14 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.armv7, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ); expect(device.isSupported(), isFalse); }); @@ -109,12 +121,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: '1.0.0', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).majorSdkVersion, 1); expect(IOSDevice( 'device-123', @@ -124,12 +139,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: '13.1.1', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).majorSdkVersion, 13); expect(IOSDevice( 'device-123', @@ -139,12 +157,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: '10', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).majorSdkVersion, 10); expect(IOSDevice( 'device-123', @@ -154,12 +175,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: '0', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).majorSdkVersion, 0); expect(IOSDevice( 'device-123', @@ -169,12 +193,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: 'bogus', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).majorSdkVersion, 0); }); @@ -187,12 +214,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: '13.3.1', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).sdkVersion; Version expectedVersion = Version(13, 3, 1, text: '13.3.1'); expect(sdkVersion, isNotNull); @@ -207,12 +237,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: '13.3.1 (20ADBC)', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).sdkVersion; expectedVersion = Version(13, 3, 1, text: '13.3.1 (20ADBC)'); expect(sdkVersion, isNotNull); @@ -227,12 +260,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: '16.4.1(a) (20ADBC)', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).sdkVersion; expectedVersion = Version(16, 4, 1, text: '16.4.1(a) (20ADBC)'); expect(sdkVersion, isNotNull); @@ -247,12 +283,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: '0', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).sdkVersion; expectedVersion = Version(0, 0, 0, text: '0'); expect(sdkVersion, isNotNull); @@ -267,11 +306,14 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).sdkVersion; expect(sdkVersion, isNull); @@ -283,12 +325,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: 'bogus', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).sdkVersion; expect(sdkVersion, isNull); }); @@ -302,12 +347,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3 17C54', cpuArchitecture: DarwinArch.arm64, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ); expect(await device.sdkNameAndVersion,'iOS 13.3 17C54'); @@ -322,12 +370,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ); expect(device.supportsRuntimeMode(BuildMode.debug), true); @@ -348,12 +399,15 @@ void main() { platform: platform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ); }, throwsAssertionError, @@ -440,12 +494,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ); logReader1 = createLogReader(device, appPackage1, process1); logReader2 = createLogReader(device, appPackage2, process2); @@ -471,6 +528,8 @@ void main() { late IOSDeploy iosDeploy; late IMobileDevice iMobileDevice; late IOSWorkflow iosWorkflow; + late IOSCoreDeviceControl coreDeviceControl; + late XcodeDebug xcodeDebug; late IOSDevice device1; late IOSDevice device2; @@ -494,6 +553,8 @@ void main() { processManager: fakeProcessManager, logger: logger, ); + coreDeviceControl = FakeIOSCoreDeviceControl(); + xcodeDebug = FakeXcodeDebug(); device1 = IOSDevice( 'd83d5bc53967baa0ee18626ba87b6254b2ab5418', @@ -503,12 +564,15 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, logger: logger, platform: macPlatform, fileSystem: MemoryFileSystem.test(), connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ); device2 = IOSDevice( @@ -519,12 +583,15 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, logger: logger, platform: macPlatform, fileSystem: MemoryFileSystem.test(), connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ); }); @@ -781,6 +848,8 @@ void main() { late IOSDeploy iosDeploy; late IMobileDevice iMobileDevice; late IOSWorkflow iosWorkflow; + late IOSCoreDeviceControl coreDeviceControl; + late XcodeDebug xcodeDebug; late IOSDevice notConnected1; setUp(() { @@ -803,6 +872,8 @@ void main() { processManager: fakeProcessManager, logger: logger, ); + coreDeviceControl = FakeIOSCoreDeviceControl(); + xcodeDebug = FakeXcodeDebug(); notConnected1 = IOSDevice( '00000001-0000000000000000', name: 'iPad', @@ -811,12 +882,15 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, logger: logger, platform: macPlatform, fileSystem: MemoryFileSystem.test(), connectionInterface: DeviceConnectionInterface.attached, isConnected: false, devModeEnabled: true, + isCoreDevice: false, ); }); @@ -965,3 +1039,10 @@ class FakeProcess extends Fake implements Process { return true; } } + +class FakeXcodeDebug extends Fake implements XcodeDebug { + @override + bool get debugStarted => false; +} + +class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl {} diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart index 8c93d8fb4f81d..6a5f5226fdba9 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart @@ -12,10 +12,13 @@ import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/device.dart'; import 'package:flutter_tools/src/ios/application_package.dart'; +import 'package:flutter_tools/src/ios/core_devices.dart'; import 'package:flutter_tools/src/ios/devices.dart'; import 'package:flutter_tools/src/ios/ios_deploy.dart'; import 'package:flutter_tools/src/ios/iproxy.dart'; import 'package:flutter_tools/src/ios/mac.dart'; +import 'package:flutter_tools/src/ios/xcode_debug.dart'; +import 'package:test/fake.dart'; import '../../src/common.dart'; import '../../src/fake_process_manager.dart'; @@ -105,6 +108,28 @@ void main() { expect(processManager, hasNoRemainingExpectations); }); + testWithoutContext('IOSDevice.installApp uses devicectl for CoreDevices', () async { + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + uncompressedBundle: fileSystem.currentDirectory, + applicationPackage: bundleDirectory, + ); + + final FakeProcessManager processManager = FakeProcessManager.empty(); + + final IOSDevice device = setUpIOSDevice( + processManager: processManager, + fileSystem: fileSystem, + interfaceType: DeviceConnectionInterface.attached, + artifacts: artifacts, + isCoreDevice: true, + ); + final bool wasInstalled = await device.installApp(iosApp); + + expect(wasInstalled, true); + expect(processManager, hasNoRemainingExpectations); + }); + testWithoutContext('IOSDevice.uninstallApp calls ios-deploy correctly', () async { final IOSApp iosApp = PrebuiltIOSApp( projectBundleId: 'app', @@ -134,6 +159,28 @@ void main() { expect(processManager, hasNoRemainingExpectations); }); + testWithoutContext('IOSDevice.uninstallApp uses devicectl for CoreDevices', () async { + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + uncompressedBundle: fileSystem.currentDirectory, + applicationPackage: bundleDirectory, + ); + + final FakeProcessManager processManager = FakeProcessManager.empty(); + + final IOSDevice device = setUpIOSDevice( + processManager: processManager, + fileSystem: fileSystem, + interfaceType: DeviceConnectionInterface.attached, + artifacts: artifacts, + isCoreDevice: true, + ); + final bool wasUninstalled = await device.uninstallApp(iosApp); + + expect(wasUninstalled, true); + expect(processManager, hasNoRemainingExpectations); + }); + group('isAppInstalled', () { testWithoutContext('catches ProcessException from ios-deploy', () async { final IOSApp iosApp = PrebuiltIOSApp( @@ -263,6 +310,28 @@ void main() { expect(processManager, hasNoRemainingExpectations); expect(logger.traceText, contains(stderr)); }); + + testWithoutContext('uses devicectl for CoreDevices', () async { + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + uncompressedBundle: fileSystem.currentDirectory, + applicationPackage: bundleDirectory, + ); + + final FakeProcessManager processManager = FakeProcessManager.empty(); + + final IOSDevice device = setUpIOSDevice( + processManager: processManager, + fileSystem: fileSystem, + interfaceType: DeviceConnectionInterface.attached, + artifacts: artifacts, + isCoreDevice: true, + ); + final bool wasInstalled = await device.isAppInstalled(iosApp); + + expect(wasInstalled, true); + expect(processManager, hasNoRemainingExpectations); + }); }); testWithoutContext('IOSDevice.installApp catches ProcessException from ios-deploy', () async { @@ -314,6 +383,8 @@ void main() { expect(wasAppUninstalled, false); }); + + } IOSDevice setUpIOSDevice({ @@ -322,6 +393,7 @@ IOSDevice setUpIOSDevice({ Logger? logger, DeviceConnectionInterface? interfaceType, Artifacts? artifacts, + bool isCoreDevice = false, }) { logger ??= BufferLogger.test(); final FakePlatform platform = FakePlatform( @@ -357,9 +429,42 @@ IOSDevice setUpIOSDevice({ artifacts: artifacts, cache: cache, ), + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug(), iProxy: IProxy.test(logger: logger, processManager: processManager), connectionInterface: interfaceType ?? DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: isCoreDevice, ); } + +class FakeXcodeDebug extends Fake implements XcodeDebug {} + +class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl { + @override + Future installApp({ + required String deviceId, + required String bundlePath, + }) async { + + return true; + } + + @override + Future uninstallApp({ + required String deviceId, + required String bundleId, + }) async { + + return true; + } + + @override + Future isAppInstalled({ + required String deviceId, + required String bundleId, + }) async { + return true; + } +} diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_logger_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_logger_test.dart index 9f65a67072a15..01506cd99dfca 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_logger_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_logger_test.dart @@ -190,7 +190,7 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt ])); }); - testWithoutContext('IOSDeviceLogReader ignores VM Service logs when attached to debugger', () async { + testWithoutContext('IOSDeviceLogReader ignores VM Service logs when attached to and received flutter logs from debugger', () async { final Event stdoutEvent = Event( kind: 'Stdout', timestamp: 0, @@ -229,14 +229,14 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt iosDeployDebugger.debuggerAttached = true; final Stream debuggingLogs = Stream.fromIterable([ - 'Message from debugger', + 'flutter: Message from debugger', ]); iosDeployDebugger.logLines = debuggingLogs; logReader.debuggerStream = iosDeployDebugger; // Wait for stream listeners to fire. await expectLater(logReader.logLines, emitsInAnyOrder([ - equals('Message from debugger'), + equals('flutter: Message from debugger'), ])); }); }); @@ -349,9 +349,8 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt }); }); - group('both syslog and debugger stream', () { - - testWithoutContext('useBothLogDeviceReaders is true when CI option is true and sdk is at least 16', () { + group('Determine which loggers to use', () { + testWithoutContext('for physically attached CoreDevice', () { final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( iMobileDevice: IMobileDevice( artifacts: artifacts, @@ -359,14 +358,18 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), - usingCISystem: true, - majorSdkVersion: 16, + majorSdkVersion: 17, + isCoreDevice: true, ); - expect(logReader.useBothLogDeviceReaders, isTrue); + expect(logReader.useSyslogLogging, isTrue); + expect(logReader.useUnifiedLogging, isTrue); + expect(logReader.useIOSDeployLogging, isFalse); + expect(logReader.logSources.primarySource, IOSDeviceLogSource.idevicesyslog); + expect(logReader.logSources.fallbackSource, IOSDeviceLogSource.unifiedLogging); }); - testWithoutContext('useBothLogDeviceReaders is false when sdk is less than 16', () { + testWithoutContext('for wirelessly attached CoreDevice', () { final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( iMobileDevice: IMobileDevice( artifacts: artifacts, @@ -374,14 +377,19 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), - usingCISystem: true, - majorSdkVersion: 15, + majorSdkVersion: 17, + isCoreDevice: true, + isWirelesslyConnected: true, ); - expect(logReader.useBothLogDeviceReaders, isFalse); + expect(logReader.useSyslogLogging, isFalse); + expect(logReader.useUnifiedLogging, isTrue); + expect(logReader.useIOSDeployLogging, isFalse); + expect(logReader.logSources.primarySource, IOSDeviceLogSource.unifiedLogging); + expect(logReader.logSources.fallbackSource, isNull); }); - testWithoutContext('useBothLogDeviceReaders is false when CI option is false', () { + testWithoutContext('for iOS 12 or less device', () { final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( iMobileDevice: IMobileDevice( artifacts: artifacts, @@ -389,27 +397,17 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt cache: fakeCache, logger: logger, ), - majorSdkVersion: 16, + majorSdkVersion: 12, ); - expect(logReader.useBothLogDeviceReaders, isFalse); + expect(logReader.useSyslogLogging, isTrue); + expect(logReader.useUnifiedLogging, isFalse); + expect(logReader.useIOSDeployLogging, isFalse); + expect(logReader.logSources.primarySource, IOSDeviceLogSource.idevicesyslog); + expect(logReader.logSources.fallbackSource, isNull); }); - testWithoutContext('syslog only sends flutter messages to stream when useBothLogDeviceReaders is true', () async { - processManager.addCommand( - FakeCommand( - command: [ - ideviceSyslogPath, '-u', '1234', - ], - stdout: ''' -Runner(Flutter)[297] : A is for ari -Runner(Flutter)[297] : I is for ichigo -May 30 13:56:28 Runner(Flutter)[2037] : flutter: The Dart VM service is listening on http://127.0.0.1:63098/35ZezGIQLnw=/ -May 30 13:56:28 Runner(Flutter)[2037] : flutter: This is a test -May 30 13:56:28 Runner(Flutter)[2037] : [VERBOSE-2:FlutterDarwinContextMetalImpeller.mm(39)] Using the Impeller rendering backend. -''' - ), - ); + testWithoutContext('for iOS 13 or greater non-CoreDevice and _iosDeployDebugger not attached', () { final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( iMobileDevice: IMobileDevice( artifacts: artifacts, @@ -417,38 +415,49 @@ May 30 13:56:28 Runner(Flutter)[2037] : [VERBOSE-2:FlutterDarwinContextM cache: fakeCache, logger: logger, ), - usingCISystem: true, - majorSdkVersion: 16, + majorSdkVersion: 13, ); - final List lines = await logReader.logLines.toList(); - expect(logReader.useBothLogDeviceReaders, isTrue); - expect(processManager, hasNoRemainingExpectations); - expect(lines, [ - 'flutter: The Dart VM service is listening on http://127.0.0.1:63098/35ZezGIQLnw=/', - 'flutter: This is a test' - ]); + expect(logReader.useSyslogLogging, isFalse); + expect(logReader.useUnifiedLogging, isTrue); + expect(logReader.useIOSDeployLogging, isTrue); + expect(logReader.logSources.primarySource, IOSDeviceLogSource.iosDeploy); + expect(logReader.logSources.fallbackSource, IOSDeviceLogSource.unifiedLogging); }); - testWithoutContext('IOSDeviceLogReader uses both syslog and ios-deploy debugger', () async { - processManager.addCommand( - FakeCommand( - command: [ - ideviceSyslogPath, '-u', '1234', - ], - stdout: ''' -May 30 13:56:28 Runner(Flutter)[2037] : flutter: The Dart VM service is listening on http://127.0.0.1:63098/35ZezGIQLnw=/ -May 30 13:56:28 Runner(Flutter)[2037] : flutter: Check for duplicate -May 30 13:56:28 Runner(Flutter)[2037] : [VERBOSE-2:FlutterDarwinContextMetalImpeller.mm(39)] Using the Impeller rendering backend. -''' + testWithoutContext('for iOS 13 or greater non-CoreDevice, _iosDeployDebugger not attached, and VM is connected', () { + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, ), + majorSdkVersion: 13, ); - final Stream debuggingLogs = Stream.fromIterable([ - '2023-06-01 12:49:01.445093-0500 Runner[2225:533240] flutter: Check for duplicate', - '(lldb) 2023-05-30 13:48:52.461894-0500 Runner[2019:1101495] [VERBOSE-2:FlutterDarwinContextMetalImpeller.mm(39)] Using the Impeller rendering backend.', - ]); + final FlutterVmService vmService = FakeVmServiceHost(requests: [ + const FakeVmServiceRequest(method: 'streamListen', args: { + 'streamId': 'Debug', + }), + const FakeVmServiceRequest(method: 'streamListen', args: { + 'streamId': 'Stdout', + }), + const FakeVmServiceRequest(method: 'streamListen', args: { + 'streamId': 'Stderr', + }), + ]).vmService; + + logReader.connectedVMService = vmService; + + expect(logReader.useSyslogLogging, isFalse); + expect(logReader.useUnifiedLogging, isTrue); + expect(logReader.useIOSDeployLogging, isTrue); + expect(logReader.logSources.primarySource, IOSDeviceLogSource.unifiedLogging); + expect(logReader.logSources.fallbackSource, IOSDeviceLogSource.iosDeploy); + }); + testWithoutContext('for iOS 13 or greater non-CoreDevice and _iosDeployDebugger is attached', () { final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( iMobileDevice: IMobileDevice( artifacts: artifacts, @@ -456,32 +465,35 @@ May 30 13:56:28 Runner(Flutter)[2037] : [VERBOSE-2:FlutterDarwinContextM cache: fakeCache, logger: logger, ), - usingCISystem: true, - majorSdkVersion: 16, + majorSdkVersion: 13, ); + final FakeIOSDeployDebugger iosDeployDebugger = FakeIOSDeployDebugger(); - iosDeployDebugger.logLines = debuggingLogs; + iosDeployDebugger.debuggerAttached = true; logReader.debuggerStream = iosDeployDebugger; - final Future> logLines = logReader.logLines.toList(); - final List lines = await logLines; - - expect(logReader.useBothLogDeviceReaders, isTrue); - expect(processManager, hasNoRemainingExpectations); - expect(lines.length, 3); - expect(lines, containsAll([ - '(lldb) 2023-05-30 13:48:52.461894-0500 Runner[2019:1101495] [VERBOSE-2:FlutterDarwinContextMetalImpeller.mm(39)] Using the Impeller rendering backend.', - 'flutter: The Dart VM service is listening on http://127.0.0.1:63098/35ZezGIQLnw=/', - 'flutter: Check for duplicate', - ])); - }); + final FlutterVmService vmService = FakeVmServiceHost(requests: [ + const FakeVmServiceRequest(method: 'streamListen', args: { + 'streamId': 'Debug', + }), + const FakeVmServiceRequest(method: 'streamListen', args: { + 'streamId': 'Stdout', + }), + const FakeVmServiceRequest(method: 'streamListen', args: { + 'streamId': 'Stderr', + }), + ]).vmService; - testWithoutContext('IOSDeviceLogReader only uses ios-deploy debugger when useBothLogDeviceReaders is false', () async { - final Stream debuggingLogs = Stream.fromIterable([ - '(lldb) 2023-05-30 13:48:52.461894-0500 Runner[2019:1101495] [VERBOSE-2:FlutterDarwinContextMetalImpeller.mm(39)] Using the Impeller rendering backend.', - '', - ]); + logReader.connectedVMService = vmService; + + expect(logReader.useSyslogLogging, isFalse); + expect(logReader.useUnifiedLogging, isTrue); + expect(logReader.useIOSDeployLogging, isTrue); + expect(logReader.logSources.primarySource, IOSDeviceLogSource.iosDeploy); + expect(logReader.logSources.fallbackSource, IOSDeviceLogSource.unifiedLogging); + }); + testWithoutContext('for iOS 16 or greater non-CoreDevice', () { final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( iMobileDevice: IMobileDevice( artifacts: artifacts, @@ -491,20 +503,490 @@ May 30 13:56:28 Runner(Flutter)[2037] : [VERBOSE-2:FlutterDarwinContextM ), majorSdkVersion: 16, ); + final FakeIOSDeployDebugger iosDeployDebugger = FakeIOSDeployDebugger(); - iosDeployDebugger.logLines = debuggingLogs; + iosDeployDebugger.debuggerAttached = true; logReader.debuggerStream = iosDeployDebugger; - final Future> logLines = logReader.logLines.toList(); - final List lines = await logLines; - expect(logReader.useBothLogDeviceReaders, isFalse); - expect(processManager, hasNoRemainingExpectations); - expect( - lines.contains( - '(lldb) 2023-05-30 13:48:52.461894-0500 Runner[2019:1101495] [VERBOSE-2:FlutterDarwinContextMetalImpeller.mm(39)] Using the Impeller rendering backend.', + expect(logReader.useSyslogLogging, isFalse); + expect(logReader.useUnifiedLogging, isTrue); + expect(logReader.useIOSDeployLogging, isTrue); + expect(logReader.logSources.primarySource, IOSDeviceLogSource.iosDeploy); + expect(logReader.logSources.fallbackSource, IOSDeviceLogSource.unifiedLogging); + }); + + testWithoutContext('for iOS 16 or greater non-CoreDevice in CI', () { + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, ), - isTrue, + usingCISystem: true, + majorSdkVersion: 16, ); + + expect(logReader.useSyslogLogging, isTrue); + expect(logReader.useUnifiedLogging, isFalse); + expect(logReader.useIOSDeployLogging, isTrue); + expect(logReader.logSources.primarySource, IOSDeviceLogSource.iosDeploy); + expect(logReader.logSources.fallbackSource, IOSDeviceLogSource.idevicesyslog); + }); + + group('when useSyslogLogging', () { + + testWithoutContext('is true syslog sends flutter messages to stream', () async { + processManager.addCommand( + FakeCommand( + command: [ + ideviceSyslogPath, '-u', '1234', + ], + stdout: ''' + Runner(Flutter)[297] : A is for ari + Runner(Flutter)[297] : I is for ichigo + May 30 13:56:28 Runner(Flutter)[2037] : flutter: The Dart VM service is listening on http://127.0.0.1:63098/35ZezGIQLnw=/ + May 30 13:56:28 Runner(Flutter)[2037] : flutter: This is a test + May 30 13:56:28 Runner(Flutter)[2037] : [VERBOSE-2:FlutterDarwinContextMetalImpeller.mm(39)] Using the Impeller rendering backend. + ''' + ), + ); + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, + ), + usingCISystem: true, + majorSdkVersion: 16, + ); + final List lines = await logReader.logLines.toList(); + + expect(logReader.useSyslogLogging, isTrue); + expect(processManager, hasNoRemainingExpectations); + expect(lines, [ + 'flutter: The Dart VM service is listening on http://127.0.0.1:63098/35ZezGIQLnw=/', + 'flutter: This is a test' + ]); + }); + + testWithoutContext('is false syslog does not send flutter messages to stream', () async { + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, + ), + majorSdkVersion: 16, + ); + + final FakeIOSDeployDebugger iosDeployDebugger = FakeIOSDeployDebugger(); + iosDeployDebugger.logLines = Stream.fromIterable([]); + logReader.debuggerStream = iosDeployDebugger; + + final List lines = await logReader.logLines.toList(); + + expect(logReader.useSyslogLogging, isFalse); + expect(processManager, hasNoRemainingExpectations); + expect(lines, isEmpty); + }); + }); + + group('when useIOSDeployLogging', () { + + testWithoutContext('is true ios-deploy sends flutter messages to stream', () async { + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, + ), + majorSdkVersion: 16, + ); + + final FakeIOSDeployDebugger iosDeployDebugger = FakeIOSDeployDebugger(); + final Stream debuggingLogs = Stream.fromIterable([ + 'flutter: Message from debugger', + ]); + iosDeployDebugger.logLines = debuggingLogs; + logReader.debuggerStream = iosDeployDebugger; + + final List lines = await logReader.logLines.toList(); + + expect(logReader.useIOSDeployLogging, isTrue); + expect(processManager, hasNoRemainingExpectations); + expect(lines, [ + 'flutter: Message from debugger', + ]); + }); + + testWithoutContext('is false ios-deploy does not send flutter messages to stream', () async { + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: FakeProcessManager.any(), + cache: fakeCache, + logger: logger, + ), + majorSdkVersion: 12, + ); + + final FakeIOSDeployDebugger iosDeployDebugger = FakeIOSDeployDebugger(); + final Stream debuggingLogs = Stream.fromIterable([ + 'flutter: Message from debugger', + ]); + iosDeployDebugger.logLines = debuggingLogs; + logReader.debuggerStream = iosDeployDebugger; + + final List lines = await logReader.logLines.toList(); + + expect(logReader.useIOSDeployLogging, isFalse); + expect(processManager, hasNoRemainingExpectations); + expect(lines, isEmpty); + }); + }); + + group('when useUnifiedLogging', () { + + + testWithoutContext('is true Dart VM sends flutter messages to stream', () async { + final Event stdoutEvent = Event( + kind: 'Stdout', + timestamp: 0, + bytes: base64.encode(utf8.encode('flutter: A flutter message')), + ); + final Event stderrEvent = Event( + kind: 'Stderr', + timestamp: 0, + bytes: base64.encode(utf8.encode('flutter: A second flutter message')), + ); + final FlutterVmService vmService = FakeVmServiceHost(requests: [ + const FakeVmServiceRequest(method: 'streamListen', args: { + 'streamId': 'Debug', + }), + const FakeVmServiceRequest(method: 'streamListen', args: { + 'streamId': 'Stdout', + }), + const FakeVmServiceRequest(method: 'streamListen', args: { + 'streamId': 'Stderr', + }), + FakeVmServiceStreamResponse(event: stdoutEvent, streamId: 'Stdout'), + FakeVmServiceStreamResponse(event: stderrEvent, streamId: 'Stderr'), + ]).vmService; + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + useSyslog: false, + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, + ), + ); + logReader.connectedVMService = vmService; + + // Wait for stream listeners to fire. + expect(logReader.useUnifiedLogging, isTrue); + expect(processManager, hasNoRemainingExpectations); + await expectLater(logReader.logLines, emitsInAnyOrder([ + equals('flutter: A flutter message'), + equals('flutter: A second flutter message'), + ])); + }); + + testWithoutContext('is false Dart VM does not send flutter messages to stream', () async { + final Event stdoutEvent = Event( + kind: 'Stdout', + timestamp: 0, + bytes: base64.encode(utf8.encode('flutter: A flutter message')), + ); + final Event stderrEvent = Event( + kind: 'Stderr', + timestamp: 0, + bytes: base64.encode(utf8.encode('flutter: A second flutter message')), + ); + final FlutterVmService vmService = FakeVmServiceHost(requests: [ + const FakeVmServiceRequest(method: 'streamListen', args: { + 'streamId': 'Debug', + }), + const FakeVmServiceRequest(method: 'streamListen', args: { + 'streamId': 'Stdout', + }), + const FakeVmServiceRequest(method: 'streamListen', args: { + 'streamId': 'Stderr', + }), + FakeVmServiceStreamResponse(event: stdoutEvent, streamId: 'Stdout'), + FakeVmServiceStreamResponse(event: stderrEvent, streamId: 'Stderr'), + ]).vmService; + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: FakeProcessManager.any(), + cache: fakeCache, + logger: logger, + ), + majorSdkVersion: 12, + ); + logReader.connectedVMService = vmService; + + final List lines = await logReader.logLines.toList(); + + // Wait for stream listeners to fire. + expect(logReader.useUnifiedLogging, isFalse); + expect(processManager, hasNoRemainingExpectations); + expect(lines, isEmpty); + }); + }); + + group('and when to exclude logs:', () { + + testWithoutContext('all primary messages are included except if fallback sent flutter message first', () async { + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: FakeProcessManager.any(), + cache: fakeCache, + logger: logger, + ), + usingCISystem: true, + majorSdkVersion: 16, + ); + + expect(logReader.useSyslogLogging, isTrue); + expect(logReader.useIOSDeployLogging, isTrue); + expect(logReader.logSources.primarySource, IOSDeviceLogSource.iosDeploy); + expect(logReader.logSources.fallbackSource, IOSDeviceLogSource.idevicesyslog); + + final Future> logLines = logReader.logLines.toList(); + + logReader.addToLinesController( + 'flutter: The Dart VM service is listening on http://127.0.0.1:63098/35ZezGIQLnw=/', + IOSDeviceLogSource.idevicesyslog, + ); + // Will be excluded because was already added by fallback. + logReader.addToLinesController( + 'flutter: The Dart VM service is listening on http://127.0.0.1:63098/35ZezGIQLnw=/', + IOSDeviceLogSource.iosDeploy, + ); + logReader.addToLinesController( + 'A second non-flutter message', + IOSDeviceLogSource.iosDeploy, + ); + logReader.addToLinesController( + 'flutter: Another flutter message', + IOSDeviceLogSource.iosDeploy, + ); + final List lines = await logLines; + + expect(lines, containsAllInOrder([ + 'flutter: The Dart VM service is listening on http://127.0.0.1:63098/35ZezGIQLnw=/', // from idevicesyslog + 'A second non-flutter message', // from iosDeploy + 'flutter: Another flutter message', // from iosDeploy + ])); + }); + + testWithoutContext('all primary messages are included when there is no fallback', () async { + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: FakeProcessManager.any(), + cache: fakeCache, + logger: logger, + ), + majorSdkVersion: 12, + ); + + expect(logReader.useSyslogLogging, isTrue); + expect(logReader.logSources.primarySource, IOSDeviceLogSource.idevicesyslog); + expect(logReader.logSources.fallbackSource, isNull); + + final Future> logLines = logReader.logLines.toList(); + + logReader.addToLinesController( + 'flutter: The Dart VM service is listening on http://127.0.0.1:63098/35ZezGIQLnw=/', + IOSDeviceLogSource.idevicesyslog, + ); + logReader.addToLinesController( + 'A non-flutter message', + IOSDeviceLogSource.idevicesyslog, + ); + logReader.addToLinesController( + 'A non-flutter message', + IOSDeviceLogSource.idevicesyslog, + ); + logReader.addToLinesController( + 'flutter: A flutter message', + IOSDeviceLogSource.idevicesyslog, + ); + logReader.addToLinesController( + 'flutter: A flutter message', + IOSDeviceLogSource.idevicesyslog, + ); + final List lines = await logLines; + + expect(lines, containsAllInOrder([ + 'flutter: The Dart VM service is listening on http://127.0.0.1:63098/35ZezGIQLnw=/', + 'A non-flutter message', + 'A non-flutter message', + 'flutter: A flutter message', + 'flutter: A flutter message', + ])); + }); + + testWithoutContext('primary messages are not added if fallback already added them, otherwise duplicates are allowed', () async { + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: FakeProcessManager.any(), + cache: fakeCache, + logger: logger, + ), + usingCISystem: true, + majorSdkVersion: 16, + ); + + expect(logReader.useSyslogLogging, isTrue); + expect(logReader.useIOSDeployLogging, isTrue); + expect(logReader.logSources.primarySource, IOSDeviceLogSource.iosDeploy); + expect(logReader.logSources.fallbackSource, IOSDeviceLogSource.idevicesyslog); + + final Future> logLines = logReader.logLines.toList(); + + logReader.addToLinesController( + 'flutter: A flutter message', + IOSDeviceLogSource.idevicesyslog, + ); + logReader.addToLinesController( + 'flutter: A flutter message', + IOSDeviceLogSource.idevicesyslog, + ); + logReader.addToLinesController( + 'A non-flutter message', + IOSDeviceLogSource.iosDeploy, + ); + logReader.addToLinesController( + 'A non-flutter message', + IOSDeviceLogSource.iosDeploy, + ); + // Will be excluded because was already added by fallback. + logReader.addToLinesController( + 'flutter: A flutter message', + IOSDeviceLogSource.iosDeploy, + ); + // Will be excluded because was already added by fallback. + logReader.addToLinesController( + 'flutter: A flutter message', + IOSDeviceLogSource.iosDeploy, + ); + // Will be included because, although the message is the same, the + // fallback only added it twice so this third one is considered new. + logReader.addToLinesController( + 'flutter: A flutter message', + IOSDeviceLogSource.iosDeploy, + ); + + final List lines = await logLines; + + expect(lines, containsAllInOrder([ + 'flutter: A flutter message', // from idevicesyslog + 'flutter: A flutter message', // from idevicesyslog + 'A non-flutter message', // from iosDeploy + 'A non-flutter message', // from iosDeploy + 'flutter: A flutter message', // from iosDeploy + ])); + }); + + testWithoutContext('flutter fallback messages are included until a primary flutter message is received', () async { + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: FakeProcessManager.any(), + cache: fakeCache, + logger: logger, + ), + usingCISystem: true, + majorSdkVersion: 16, + ); + + expect(logReader.useSyslogLogging, isTrue); + expect(logReader.useIOSDeployLogging, isTrue); + expect(logReader.logSources.primarySource, IOSDeviceLogSource.iosDeploy); + expect(logReader.logSources.fallbackSource, IOSDeviceLogSource.idevicesyslog); + + final Future> logLines = logReader.logLines.toList(); + + logReader.addToLinesController( + 'flutter: The Dart VM service is listening on http://127.0.0.1:63098/35ZezGIQLnw=/', + IOSDeviceLogSource.idevicesyslog, + ); + logReader.addToLinesController( + 'A second non-flutter message', + IOSDeviceLogSource.iosDeploy, + ); + // Will be included because the first log from primary source wasn't a + // flutter log. + logReader.addToLinesController( + 'flutter: A flutter message', + IOSDeviceLogSource.idevicesyslog, + ); + // Will be excluded because was already added by fallback, however, it + // will be used to determine a flutter log was received by the primary source. + logReader.addToLinesController( + 'flutter: The Dart VM service is listening on http://127.0.0.1:63098/35ZezGIQLnw=/', + IOSDeviceLogSource.iosDeploy, + ); + // Will be excluded because flutter log from primary was received. + logReader.addToLinesController( + 'flutter: A third flutter message', + IOSDeviceLogSource.idevicesyslog, + ); + + final List lines = await logLines; + + expect(lines, containsAllInOrder([ + 'flutter: The Dart VM service is listening on http://127.0.0.1:63098/35ZezGIQLnw=/', // from idevicesyslog + 'A second non-flutter message', // from iosDeploy + 'flutter: A flutter message', // from idevicesyslog + ])); + }); + + testWithoutContext('non-flutter fallback messages are not included', () async { + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: FakeProcessManager.any(), + cache: fakeCache, + logger: logger, + ), + usingCISystem: true, + majorSdkVersion: 16, + ); + + expect(logReader.useSyslogLogging, isTrue); + expect(logReader.useIOSDeployLogging, isTrue); + expect(logReader.logSources.primarySource, IOSDeviceLogSource.iosDeploy); + expect(logReader.logSources.fallbackSource, IOSDeviceLogSource.idevicesyslog); + + final Future> logLines = logReader.logLines.toList(); + + logReader.addToLinesController( + 'flutter: A flutter message', + IOSDeviceLogSource.idevicesyslog, + ); + // Will be excluded because it's from fallback and not a flutter message. + logReader.addToLinesController( + 'A non-flutter message', + IOSDeviceLogSource.idevicesyslog, + ); + + final List lines = await logLines; + + expect(lines, containsAllInOrder([ + 'flutter: A flutter message', + ])); + }); }); }); } diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_project_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_project_test.dart index 082d70b77beee..abe1e850a4a1a 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_project_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_project_test.dart @@ -10,11 +10,14 @@ import 'package:flutter_tools/src/base/platform.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/device.dart'; +import 'package:flutter_tools/src/ios/core_devices.dart'; import 'package:flutter_tools/src/ios/devices.dart'; import 'package:flutter_tools/src/ios/ios_deploy.dart'; import 'package:flutter_tools/src/ios/iproxy.dart'; import 'package:flutter_tools/src/ios/mac.dart'; +import 'package:flutter_tools/src/ios/xcode_debug.dart'; import 'package:flutter_tools/src/project.dart'; +import 'package:test/fake.dart'; import '../../src/common.dart'; import '../../src/context.dart'; @@ -94,6 +97,8 @@ IOSDevice setUpIOSDevice(FileSystem fileSystem) { cache: Cache.test(processManager: processManager), ), iMobileDevice: IMobileDevice.test(processManager: processManager), + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug(), platform: platform, name: 'iPhone 1', sdkVersion: '13.3', @@ -102,5 +107,10 @@ IOSDevice setUpIOSDevice(FileSystem fileSystem) { connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ); } + +class FakeXcodeDebug extends Fake implements XcodeDebug {} + +class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl {} diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart index be0d51a3438ad..d7f354cd6148a 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:fake_async/fake_async.dart'; import 'package:file/memory.dart'; import 'package:file_testing/file_testing.dart'; @@ -13,11 +15,14 @@ import 'package:flutter_tools/src/base/version.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/device.dart'; +import 'package:flutter_tools/src/device_port_forwarder.dart'; import 'package:flutter_tools/src/ios/application_package.dart'; +import 'package:flutter_tools/src/ios/core_devices.dart'; import 'package:flutter_tools/src/ios/devices.dart'; import 'package:flutter_tools/src/ios/ios_deploy.dart'; import 'package:flutter_tools/src/ios/iproxy.dart'; import 'package:flutter_tools/src/ios/mac.dart'; +import 'package:flutter_tools/src/ios/xcode_debug.dart'; import 'package:flutter_tools/src/ios/xcodeproj.dart'; import 'package:flutter_tools/src/macos/xcode.dart'; import 'package:flutter_tools/src/project.dart'; @@ -25,6 +30,7 @@ import 'package:test/fake.dart'; import '../../src/common.dart'; import '../../src/context.dart' hide FakeXcodeProjectInterpreter; +import '../../src/fake_devices.dart'; import '../../src/fake_process_manager.dart'; import '../../src/fakes.dart'; @@ -287,13 +293,363 @@ void main() { Xcode: () => xcode, }, skip: true); // TODO(zanderso): clean up with https://github.com/flutter/flutter/issues/60675 }); + + group('IOSDevice.startApp for CoreDevice', () { + late FileSystem fileSystem; + late FakeProcessManager processManager; + late BufferLogger logger; + late Xcode xcode; + late FakeXcodeProjectInterpreter fakeXcodeProjectInterpreter; + late XcodeProjectInfo projectInfo; + + setUp(() { + logger = BufferLogger.test(); + fileSystem = MemoryFileSystem.test(); + processManager = FakeProcessManager.empty(); + projectInfo = XcodeProjectInfo( + ['Runner'], + ['Debug', 'Release'], + ['Runner'], + logger, + ); + fakeXcodeProjectInterpreter = FakeXcodeProjectInterpreter(projectInfo: projectInfo); + xcode = Xcode.test(processManager: FakeProcessManager.any(), xcodeProjectInterpreter: fakeXcodeProjectInterpreter); + fileSystem.file('foo/.packages') + ..createSync(recursive: true) + ..writeAsStringSync('\n'); + }); + + group('in release mode', () { + testUsingContext('suceeds when install and launch succeed', () async { + final IOSDevice iosDevice = setUpIOSDevice( + fileSystem: fileSystem, + processManager: FakeProcessManager.any(), + logger: logger, + artifacts: artifacts, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl(), + ); + setUpIOSProject(fileSystem); + final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); + final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); + + final LaunchResult launchResult = await iosDevice.startApp( + buildableIOSApp, + debuggingOptions: DebuggingOptions.disabled(BuildInfo.release), + platformArgs: {}, + ); + + expect(fileSystem.directory('build/ios/iphoneos'), exists); + expect(launchResult.started, true); + expect(processManager, hasNoRemainingExpectations); + }, overrides: { + ProcessManager: () => FakeProcessManager.any(), + FileSystem: () => fileSystem, + Logger: () => logger, + Platform: () => macPlatform, + XcodeProjectInterpreter: () => fakeXcodeProjectInterpreter, + Xcode: () => xcode, + }); + + testUsingContext('fails when install fails', () async { + final IOSDevice iosDevice = setUpIOSDevice( + fileSystem: fileSystem, + processManager: FakeProcessManager.any(), + logger: logger, + artifacts: artifacts, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl( + installSuccess: false, + ), + ); + setUpIOSProject(fileSystem); + final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); + final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); + + final LaunchResult launchResult = await iosDevice.startApp( + buildableIOSApp, + debuggingOptions: DebuggingOptions.disabled(BuildInfo.release), + platformArgs: {}, + ); + + expect(fileSystem.directory('build/ios/iphoneos'), exists); + expect(launchResult.started, false); + expect(processManager, hasNoRemainingExpectations); + }, overrides: { + ProcessManager: () => FakeProcessManager.any(), + FileSystem: () => fileSystem, + Logger: () => logger, + Platform: () => macPlatform, + XcodeProjectInterpreter: () => fakeXcodeProjectInterpreter, + Xcode: () => xcode, + }); + + testUsingContext('fails when launch fails', () async { + final IOSDevice iosDevice = setUpIOSDevice( + fileSystem: fileSystem, + processManager: FakeProcessManager.any(), + logger: logger, + artifacts: artifacts, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl( + launchSuccess: false, + ), + ); + setUpIOSProject(fileSystem); + final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); + final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); + + final LaunchResult launchResult = await iosDevice.startApp( + buildableIOSApp, + debuggingOptions: DebuggingOptions.disabled(BuildInfo.release), + platformArgs: {}, + ); + + expect(fileSystem.directory('build/ios/iphoneos'), exists); + expect(launchResult.started, false); + expect(processManager, hasNoRemainingExpectations); + }, overrides: { + ProcessManager: () => FakeProcessManager.any(), + FileSystem: () => fileSystem, + Logger: () => logger, + Platform: () => macPlatform, + XcodeProjectInterpreter: () => fakeXcodeProjectInterpreter, + Xcode: () => xcode, + }); + + testUsingContext('ensure arguments passed to launch', () async { + final FakeIOSCoreDeviceControl coreDeviceControl = FakeIOSCoreDeviceControl(); + final IOSDevice iosDevice = setUpIOSDevice( + fileSystem: fileSystem, + processManager: FakeProcessManager.any(), + logger: logger, + artifacts: artifacts, + isCoreDevice: true, + coreDeviceControl: coreDeviceControl, + ); + setUpIOSProject(fileSystem); + final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); + final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); + + final LaunchResult launchResult = await iosDevice.startApp( + buildableIOSApp, + debuggingOptions: DebuggingOptions.disabled(BuildInfo.release), + platformArgs: {}, + ); + + expect(fileSystem.directory('build/ios/iphoneos'), exists); + expect(launchResult.started, true); + expect(processManager, hasNoRemainingExpectations); + expect(coreDeviceControl.argumentsUsedForLaunch, isNotNull); + expect(coreDeviceControl.argumentsUsedForLaunch, contains('--enable-dart-profiling')); + }, overrides: { + ProcessManager: () => FakeProcessManager.any(), + FileSystem: () => fileSystem, + Logger: () => logger, + Platform: () => macPlatform, + XcodeProjectInterpreter: () => fakeXcodeProjectInterpreter, + Xcode: () => xcode, + }); + + }); + + group('in debug mode', () { + + testUsingContext('succeeds', () async { + final IOSDevice iosDevice = setUpIOSDevice( + fileSystem: fileSystem, + processManager: FakeProcessManager.any(), + logger: logger, + artifacts: artifacts, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug( + expectedProject: XcodeDebugProject( + scheme: 'Runner', + xcodeWorkspace: fileSystem.directory('/ios/Runner.xcworkspace'), + xcodeProject: fileSystem.directory('/ios/Runner.xcodeproj'), + ), + expectedDeviceId: '123', + expectedLaunchArguments: ['--enable-dart-profiling'], + ), + ); + + setUpIOSProject(fileSystem); + final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); + final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); + + final FakeDeviceLogReader deviceLogReader = FakeDeviceLogReader(); + + iosDevice.portForwarder = const NoOpDevicePortForwarder(); + iosDevice.setLogReader(buildableIOSApp, deviceLogReader); + + // Start writing messages to the log reader. + Timer.run(() { + deviceLogReader.addLine('Foo'); + deviceLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:456'); + }); + + final LaunchResult launchResult = await iosDevice.startApp( + buildableIOSApp, + debuggingOptions: DebuggingOptions.enabled(const BuildInfo( + BuildMode.debug, + null, + buildName: '1.2.3', + buildNumber: '4', + treeShakeIcons: false, + )), + platformArgs: {}, + ); + + expect(logger.errorText, isEmpty); + expect(fileSystem.directory('build/ios/iphoneos'), exists); + expect(launchResult.started, true); + expect(processManager, hasNoRemainingExpectations); + }, overrides: { + ProcessManager: () => FakeProcessManager.any(), + FileSystem: () => fileSystem, + Logger: () => logger, + Platform: () => macPlatform, + XcodeProjectInterpreter: () => fakeXcodeProjectInterpreter, + Xcode: () => xcode, + }); + + testUsingContext('fails when Xcode project is not found', () async { + final IOSDevice iosDevice = setUpIOSDevice( + fileSystem: fileSystem, + processManager: FakeProcessManager.any(), + logger: logger, + artifacts: artifacts, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl() + ); + setUpIOSProject(fileSystem); + final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); + final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); + + final LaunchResult launchResult = await iosDevice.startApp( + buildableIOSApp, + debuggingOptions: DebuggingOptions.enabled(const BuildInfo( + BuildMode.debug, + null, + buildName: '1.2.3', + buildNumber: '4', + treeShakeIcons: false, + )), + platformArgs: {}, + ); + expect(logger.errorText, contains('Xcode project not found')); + expect(fileSystem.directory('build/ios/iphoneos'), exists); + expect(launchResult.started, false); + expect(processManager, hasNoRemainingExpectations); + }, overrides: { + ProcessManager: () => FakeProcessManager.any(), + FileSystem: () => fileSystem, + Logger: () => logger, + Platform: () => macPlatform, + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreter(), + Xcode: () => xcode, + }); + + testUsingContext('fails when Xcode workspace is not found', () async { + final IOSDevice iosDevice = setUpIOSDevice( + fileSystem: fileSystem, + processManager: FakeProcessManager.any(), + logger: logger, + artifacts: artifacts, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl() + ); + setUpIOSProject(fileSystem, createWorkspace: false); + final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); + final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); + + final LaunchResult launchResult = await iosDevice.startApp( + buildableIOSApp, + debuggingOptions: DebuggingOptions.enabled(const BuildInfo( + BuildMode.debug, + null, + buildName: '1.2.3', + buildNumber: '4', + treeShakeIcons: false, + )), + platformArgs: {}, + ); + expect(logger.errorText, contains('Unable to get Xcode workspace')); + expect(fileSystem.directory('build/ios/iphoneos'), exists); + expect(launchResult.started, false); + expect(processManager, hasNoRemainingExpectations); + }, overrides: { + ProcessManager: () => FakeProcessManager.any(), + FileSystem: () => fileSystem, + Logger: () => logger, + Platform: () => macPlatform, + XcodeProjectInterpreter: () => fakeXcodeProjectInterpreter, + Xcode: () => xcode, + }); + + testUsingContext('fails when scheme is not found', () async { + final IOSDevice iosDevice = setUpIOSDevice( + fileSystem: fileSystem, + processManager: FakeProcessManager.any(), + logger: logger, + artifacts: artifacts, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl() + ); + setUpIOSProject(fileSystem); + final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); + final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); + + final FakeDeviceLogReader deviceLogReader = FakeDeviceLogReader(); + + iosDevice.portForwarder = const NoOpDevicePortForwarder(); + iosDevice.setLogReader(buildableIOSApp, deviceLogReader); + + // Start writing messages to the log reader. + Timer.run(() { + deviceLogReader.addLine('Foo'); + deviceLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:456'); + }); + + expect(() async => iosDevice.startApp( + buildableIOSApp, + debuggingOptions: DebuggingOptions.enabled(const BuildInfo( + BuildMode.debug, + 'Flavor', + buildName: '1.2.3', + buildNumber: '4', + treeShakeIcons: false, + )), + platformArgs: {}, + ), throwsToolExit()); + }, overrides: { + ProcessManager: () => FakeProcessManager.any(), + FileSystem: () => fileSystem, + Logger: () => logger, + Platform: () => macPlatform, + XcodeProjectInterpreter: () => fakeXcodeProjectInterpreter, + Xcode: () => xcode, + }); + }); + }); } -void setUpIOSProject(FileSystem fileSystem) { +void setUpIOSProject(FileSystem fileSystem, {bool createWorkspace = true}) { fileSystem.file('pubspec.yaml').createSync(); fileSystem.file('.packages').writeAsStringSync('\n'); fileSystem.directory('ios').createSync(); - fileSystem.directory('ios/Runner.xcworkspace').createSync(); + if (createWorkspace) { + fileSystem.directory('ios/Runner.xcworkspace').createSync(); + } fileSystem.file('ios/Runner.xcodeproj/project.pbxproj').createSync(recursive: true); // This is the expected output directory. fileSystem.directory('build/ios/iphoneos/My Super Awesome App.app').createSync(recursive: true); @@ -305,6 +661,9 @@ IOSDevice setUpIOSDevice({ Logger? logger, ProcessManager? processManager, Artifacts? artifacts, + bool isCoreDevice = false, + IOSCoreDeviceControl? coreDeviceControl, + FakeXcodeDebug? xcodeDebug, }) { artifacts ??= Artifacts.test(); final Cache cache = Cache.test( @@ -336,10 +695,13 @@ IOSDevice setUpIOSDevice({ artifacts: artifacts, cache: cache, ), + coreDeviceControl: coreDeviceControl ?? FakeIOSCoreDeviceControl(), + xcodeDebug: xcodeDebug ?? FakeXcodeDebug(), cpuArchitecture: DarwinArch.arm64, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: isCoreDevice, ); } @@ -381,3 +743,70 @@ class FakeXcodeProjectInterpreter extends Fake implements XcodeProjectInterprete Duration timeout = const Duration(minutes: 1), }) async => buildSettings; } + +class FakeXcodeDebug extends Fake implements XcodeDebug { + FakeXcodeDebug({ + this.debugSuccess = true, + this.expectedProject, + this.expectedDeviceId, + this.expectedLaunchArguments, + }); + + final bool debugSuccess; + + final XcodeDebugProject? expectedProject; + final String? expectedDeviceId; + final List? expectedLaunchArguments; + + @override + Future debugApp({ + required XcodeDebugProject project, + required String deviceId, + required List launchArguments, + }) async { + if (expectedProject != null) { + expect(project.scheme, expectedProject!.scheme); + expect(project.xcodeWorkspace.path, expectedProject!.xcodeWorkspace.path); + expect(project.xcodeProject.path, expectedProject!.xcodeProject.path); + expect(project.isTemporaryProject, expectedProject!.isTemporaryProject); + } + if (expectedDeviceId != null) { + expect(deviceId, expectedDeviceId); + } + if (expectedLaunchArguments != null) { + expect(expectedLaunchArguments, launchArguments); + } + return debugSuccess; + } +} + +class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl { + FakeIOSCoreDeviceControl({ + this.installSuccess = true, + this.launchSuccess = true + }); + + final bool installSuccess; + final bool launchSuccess; + List? _launchArguments; + + List? get argumentsUsedForLaunch => _launchArguments; + + @override + Future installApp({ + required String deviceId, + required String bundlePath, + }) async { + return installSuccess; + } + + @override + Future launchApp({ + required String deviceId, + required String bundleId, + List launchArguments = const [], + }) async { + _launchArguments = launchArguments; + return launchSuccess; + } +} diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart index 5d01888b0c471..8e8e2235a6bcf 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart @@ -5,20 +5,25 @@ import 'dart:async'; import 'dart:convert'; +import 'package:fake_async/fake_async.dart'; import 'package:file/memory.dart'; import 'package:flutter_tools/src/artifacts.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/platform.dart'; +import 'package:flutter_tools/src/base/process.dart'; +import 'package:flutter_tools/src/base/template.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/device.dart'; import 'package:flutter_tools/src/device_port_forwarder.dart'; import 'package:flutter_tools/src/ios/application_package.dart'; +import 'package:flutter_tools/src/ios/core_devices.dart'; import 'package:flutter_tools/src/ios/devices.dart'; import 'package:flutter_tools/src/ios/ios_deploy.dart'; import 'package:flutter_tools/src/ios/iproxy.dart'; import 'package:flutter_tools/src/ios/mac.dart'; +import 'package:flutter_tools/src/ios/xcode_debug.dart'; import 'package:flutter_tools/src/mdns_discovery.dart'; import 'package:test/fake.dart'; @@ -601,6 +606,212 @@ void main() { expect(await device.stopApp(iosApp), false); expect(processManager, hasNoRemainingExpectations); }); + + group('IOSDevice.startApp for CoreDevice', () { + group('in debug mode', () { + testUsingContext('succeeds', () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final FakeProcessManager processManager = FakeProcessManager.empty(); + + final Directory temporaryXcodeProjectDirectory = fileSystem.systemTempDirectory.childDirectory('flutter_empty_xcode.rand0'); + final Directory bundleLocation = fileSystem.currentDirectory; + final IOSDevice device = setUpIOSDevice( + processManager: processManager, + fileSystem: fileSystem, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug( + expectedProject: XcodeDebugProject( + scheme: 'Runner', + xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'), + xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'), + ), + expectedDeviceId: '123', + expectedLaunchArguments: ['--enable-dart-profiling'], + expectedBundlePath: bundleLocation.path, + ) + ); + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + bundleName: 'Runner', + uncompressedBundle: bundleLocation, + applicationPackage: bundleLocation, + ); + final FakeDeviceLogReader deviceLogReader = FakeDeviceLogReader(); + + device.portForwarder = const NoOpDevicePortForwarder(); + device.setLogReader(iosApp, deviceLogReader); + + // Start writing messages to the log reader. + Timer.run(() { + deviceLogReader.addLine('Foo'); + deviceLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:456'); + }); + + final LaunchResult launchResult = await device.startApp(iosApp, + prebuiltApplication: true, + debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), + platformArgs: {}, + ); + + expect(launchResult.started, true); + }); + + testUsingContext('prints warning message if it takes too long to start debugging', () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final FakeProcessManager processManager = FakeProcessManager.empty(); + final BufferLogger logger = BufferLogger.test(); + final Directory temporaryXcodeProjectDirectory = fileSystem.systemTempDirectory.childDirectory('flutter_empty_xcode.rand0'); + final Directory bundleLocation = fileSystem.currentDirectory; + final Completer completer = Completer(); + final FakeXcodeDebug xcodeDebug = FakeXcodeDebug( + expectedProject: XcodeDebugProject( + scheme: 'Runner', + xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'), + xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'), + ), + expectedDeviceId: '123', + expectedLaunchArguments: ['--enable-dart-profiling'], + expectedBundlePath: bundleLocation.path, + completer: completer, + ); + final IOSDevice device = setUpIOSDevice( + processManager: processManager, + fileSystem: fileSystem, + logger: logger, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: xcodeDebug, + ); + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + bundleName: 'Runner', + uncompressedBundle: bundleLocation, + applicationPackage: bundleLocation, + ); + final FakeDeviceLogReader deviceLogReader = FakeDeviceLogReader(); + + device.portForwarder = const NoOpDevicePortForwarder(); + device.setLogReader(iosApp, deviceLogReader); + + // Start writing messages to the log reader. + Timer.run(() { + deviceLogReader.addLine('Foo'); + deviceLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:456'); + }); + + FakeAsync().run((FakeAsync fakeAsync) { + device.startApp(iosApp, + prebuiltApplication: true, + debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), + platformArgs: {}, + ); + + fakeAsync.flushTimers(); + expect(logger.errorText, contains('Xcode is taking longer than expected to start debugging the app. Ensure the project is opened in Xcode.')); + completer.complete(); + }); + }); + + testUsingContext('succeeds with shutdown hook added when running from CI', () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final FakeProcessManager processManager = FakeProcessManager.empty(); + + final Directory temporaryXcodeProjectDirectory = fileSystem.systemTempDirectory.childDirectory('flutter_empty_xcode.rand0'); + final Directory bundleLocation = fileSystem.currentDirectory; + final IOSDevice device = setUpIOSDevice( + processManager: processManager, + fileSystem: fileSystem, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug( + expectedProject: XcodeDebugProject( + scheme: 'Runner', + xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'), + xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'), + ), + expectedDeviceId: '123', + expectedLaunchArguments: ['--enable-dart-profiling'], + expectedBundlePath: bundleLocation.path, + ) + ); + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + bundleName: 'Runner', + uncompressedBundle: bundleLocation, + applicationPackage: bundleLocation, + ); + final FakeDeviceLogReader deviceLogReader = FakeDeviceLogReader(); + + device.portForwarder = const NoOpDevicePortForwarder(); + device.setLogReader(iosApp, deviceLogReader); + + // Start writing messages to the log reader. + Timer.run(() { + deviceLogReader.addLine('Foo'); + deviceLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:456'); + }); + + final FakeShutDownHooks shutDownHooks = FakeShutDownHooks(); + + final LaunchResult launchResult = await device.startApp(iosApp, + prebuiltApplication: true, + debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug, usingCISystem: true), + platformArgs: {}, + shutdownHooks: shutDownHooks, + ); + + expect(launchResult.started, true); + expect(shutDownHooks.hooks.length, 1); + }); + + testUsingContext('IOSDevice.startApp attaches in debug mode via mDNS when device logging fails', () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final FakeProcessManager processManager = FakeProcessManager.empty(); + + final Directory temporaryXcodeProjectDirectory = fileSystem.systemTempDirectory.childDirectory('flutter_empty_xcode.rand0'); + final Directory bundleLocation = fileSystem.currentDirectory; + final IOSDevice device = setUpIOSDevice( + processManager: processManager, + fileSystem: fileSystem, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug( + expectedProject: XcodeDebugProject( + scheme: 'Runner', + xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'), + xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'), + ), + expectedDeviceId: '123', + expectedLaunchArguments: ['--enable-dart-profiling'], + expectedBundlePath: bundleLocation.path, + ) + ); + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + bundleName: 'Runner', + uncompressedBundle: bundleLocation, + applicationPackage: bundleLocation, + ); + final FakeDeviceLogReader deviceLogReader = FakeDeviceLogReader(); + + device.portForwarder = const NoOpDevicePortForwarder(); + device.setLogReader(iosApp, deviceLogReader); + + final LaunchResult launchResult = await device.startApp(iosApp, + prebuiltApplication: true, + debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), + platformArgs: {}, + ); + + expect(launchResult.started, true); + expect(launchResult.hasVmService, true); + expect(await device.stopApp(iosApp), true); + }, overrides: { + MDnsVmServiceDiscovery: () => FakeMDnsVmServiceDiscovery(), + }); + }); + }); } IOSDevice setUpIOSDevice({ @@ -610,6 +821,9 @@ IOSDevice setUpIOSDevice({ ProcessManager? processManager, IOSDeploy? iosDeploy, DeviceConnectionInterface interfaceType = DeviceConnectionInterface.attached, + bool isCoreDevice = false, + IOSCoreDeviceControl? coreDeviceControl, + FakeXcodeDebug? xcodeDebug, }) { final Artifacts artifacts = Artifacts.test(); final FakePlatform macPlatform = FakePlatform( @@ -646,10 +860,13 @@ IOSDevice setUpIOSDevice({ artifacts: artifacts, cache: cache, ), + coreDeviceControl: coreDeviceControl ?? FakeIOSCoreDeviceControl(), + xcodeDebug: xcodeDebug ?? FakeXcodeDebug(), cpuArchitecture: DarwinArch.arm64, connectionInterface: interfaceType, isConnected: true, devModeEnabled: true, + isCoreDevice: isCoreDevice, ); } @@ -669,10 +886,88 @@ class FakeMDnsVmServiceDiscovery extends Fake implements MDnsVmServiceDiscovery Device device, { bool usesIpv6 = false, int? hostVmservicePort, - required int deviceVmservicePort, + int? deviceVmservicePort, bool useDeviceIPAsHost = false, Duration timeout = Duration.zero, }) async { return Uri.tryParse('http://0.0.0.0:1234'); } } + +class FakeXcodeDebug extends Fake implements XcodeDebug { + FakeXcodeDebug({ + this.debugSuccess = true, + this.expectedProject, + this.expectedDeviceId, + this.expectedLaunchArguments, + this.expectedBundlePath, + this.completer, + }); + + final bool debugSuccess; + final XcodeDebugProject? expectedProject; + final String? expectedDeviceId; + final List? expectedLaunchArguments; + final String? expectedBundlePath; + final Completer? completer; + + @override + bool debugStarted = false; + + @override + Future createXcodeProjectWithCustomBundle( + String deviceBundlePath, { + required TemplateRenderer templateRenderer, + Directory? projectDestination, + bool verboseLogging = false, + }) async { + if (expectedBundlePath != null) { + expect(expectedBundlePath, deviceBundlePath); + } + return expectedProject!; + } + + @override + Future debugApp({ + required XcodeDebugProject project, + required String deviceId, + required List launchArguments, + }) async { + if (expectedProject != null) { + expect(project.scheme, expectedProject!.scheme); + expect(project.xcodeWorkspace.path, expectedProject!.xcodeWorkspace.path); + expect(project.xcodeProject.path, expectedProject!.xcodeProject.path); + expect(project.isTemporaryProject, expectedProject!.isTemporaryProject); + } + if (expectedDeviceId != null) { + expect(deviceId, expectedDeviceId); + } + if (expectedLaunchArguments != null) { + expect(expectedLaunchArguments, launchArguments); + } + debugStarted = debugSuccess; + + if (completer != null) { + await completer!.future; + } + return debugSuccess; + } + + @override + Future exit({ + bool force = false, + bool skipDelay = false, + }) async { + return true; + } +} + +class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl {} + +class FakeShutDownHooks extends Fake implements ShutdownHooks { + List hooks = []; + @override + void addShutdownHook(ShutdownHook shutdownHook) { + hooks.add(shutdownHook); + } +} diff --git a/packages/flutter_tools/test/general.shard/ios/xcode_debug_test.dart b/packages/flutter_tools/test/general.shard/ios/xcode_debug_test.dart new file mode 100644 index 0000000000000..cbd2416c2d9c2 --- /dev/null +++ b/packages/flutter_tools/test/general.shard/ios/xcode_debug_test.dart @@ -0,0 +1,1136 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io' as io; + +import 'package:file/memory.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/base/io.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/version.dart'; +import 'package:flutter_tools/src/globals.dart' as globals; +import 'package:flutter_tools/src/ios/xcode_debug.dart'; +import 'package:flutter_tools/src/ios/xcodeproj.dart'; +import 'package:flutter_tools/src/macos/xcode.dart'; +import 'package:test/fake.dart'; + +import '../../src/common.dart'; +import '../../src/context.dart'; +import '../../src/fake_process_manager.dart'; + +void main() { + group('Debug project through Xcode', () { + late MemoryFileSystem fileSystem; + late BufferLogger logger; + late FakeProcessManager fakeProcessManager; + + const String flutterRoot = '/path/to/flutter'; + const String pathToXcodeAutomationScript = '$flutterRoot/packages/flutter_tools/bin/xcode_debug.js'; + + setUp(() { + fileSystem = MemoryFileSystem.test(); + logger = BufferLogger.test(); + fakeProcessManager = FakeProcessManager.empty(); + }); + + group('debugApp', () { + const String pathToXcodeApp = '/Applications/Xcode.app'; + const String deviceId = '0000001234'; + + late Xcode xcode; + late Directory xcodeproj; + late Directory xcworkspace; + late XcodeDebugProject project; + + setUp(() { + xcode = setupXcode( + fakeProcessManager: fakeProcessManager, + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + + xcodeproj = fileSystem.directory('Runner.xcodeproj'); + xcworkspace = fileSystem.directory('Runner.xcworkspace'); + project = XcodeDebugProject( + scheme: 'Runner', + xcodeProject: xcodeproj, + xcodeWorkspace: xcworkspace, + ); + }); + + testWithoutContext('succeeds in opening and debugging with launch options and verbose logging', () async { + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'check-workspace-opened', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--verbose', + ], + stdout: ''' + {"status":false,"errorMessage":"Xcode is not running","debugResult":null} + ''', + ), + FakeCommand( + command: [ + 'open', + '-a', + pathToXcodeApp, + '-g', + '-j', + xcworkspace.path + ], + ), + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'debug', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--device-id', + deviceId, + '--scheme', + project.scheme, + '--skip-building', + '--launch-args', + r'["--enable-dart-profiling","--trace-allowlist=\"foo,bar\""]', + '--verbose', + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":{"completed":false,"status":"running","errorMessage":null}} + ''', + ), + ]); + + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + project = XcodeDebugProject( + scheme: 'Runner', + xcodeProject: xcodeproj, + xcodeWorkspace: xcworkspace, + verboseLogging: true, + ); + + final bool status = await xcodeDebug.debugApp( + project: project, + deviceId: deviceId, + launchArguments: [ + '--enable-dart-profiling', + '--trace-allowlist="foo,bar"' + ], + ); + + expect(logger.errorText, isEmpty); + expect(logger.traceText, contains('Error checking if project opened in Xcode')); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(xcodeDebug.startDebugActionProcess, isNull); + expect(status, true); + }); + + testWithoutContext('succeeds in opening and debugging without launch options and verbose logging', () async { + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'check-workspace-opened', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + ], + stdout: ''' + {"status":false,"errorMessage":"Xcode is not running","debugResult":null} + ''', + ), + FakeCommand( + command: [ + 'open', + '-a', + pathToXcodeApp, + '-g', + '-j', + xcworkspace.path + ], + ), + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'debug', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--device-id', + deviceId, + '--scheme', + project.scheme, + '--skip-building', + '--launch-args', + '[]' + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":{"completed":false,"status":"running","errorMessage":null}} + ''', + ), + ]); + + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + final bool status = await xcodeDebug.debugApp( + project: project, + deviceId: deviceId, + launchArguments: [], + ); + + expect(logger.errorText, isEmpty); + expect(logger.traceText, contains('Error checking if project opened in Xcode')); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(xcodeDebug.startDebugActionProcess, isNull); + expect(status, true); + }); + + testWithoutContext('fails if project fails to open', () async { + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'check-workspace-opened', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + ], + stdout: ''' + {"status":false,"errorMessage":"Xcode is not running","debugResult":null} + ''', + ), + FakeCommand( + command: [ + 'open', + '-a', + pathToXcodeApp, + '-g', + '-j', + xcworkspace.path + ], + exception: ProcessException( + 'open', + [ + '-a', + '/non_existant_path', + '-g', + '-j', + xcworkspace.path, + ], + 'The application /non_existant_path cannot be opened for an unexpected reason', + ), + ), + ]); + + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + final bool status = await xcodeDebug.debugApp( + project: project, + deviceId: deviceId, + launchArguments: [ + '--enable-dart-profiling', + '--trace-allowlist="foo,bar"', + ], + ); + + expect( + logger.errorText, + contains('The application /non_existant_path cannot be opened for an unexpected reason'), + ); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(status, false); + }); + + testWithoutContext('fails if osascript errors', () async { + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'check-workspace-opened', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + ], + stdout: ''' + {"status":true,"errorMessage":"","debugResult":null} + ''', + ), + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'debug', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--device-id', + deviceId, + '--scheme', + project.scheme, + '--skip-building', + '--launch-args', + r'["--enable-dart-profiling","--trace-allowlist=\"foo,bar\""]' + ], + exitCode: 1, + stderr: "/flutter/packages/flutter_tools/bin/xcode_debug.js: execution error: Error: ReferenceError: Can't find variable: y (-2700)", + ), + ]); + + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + final bool status = await xcodeDebug.debugApp( + project: project, + deviceId: deviceId, + launchArguments: [ + '--enable-dart-profiling', + '--trace-allowlist="foo,bar"', + ], + ); + + expect(logger.errorText, contains('Error executing osascript')); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(status, false); + }); + + testWithoutContext('fails if osascript output returns false status', () async { + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'check-workspace-opened', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":null} + ''', + ), + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'debug', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--device-id', + deviceId, + '--scheme', + project.scheme, + '--skip-building', + '--launch-args', + r'["--enable-dart-profiling","--trace-allowlist=\"foo,bar\""]' + ], + stdout: ''' + {"status":false,"errorMessage":"Unable to find target device.","debugResult":null} + ''', + ), + ]); + + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + final bool status = await xcodeDebug.debugApp( + project: project, + deviceId: deviceId, + launchArguments: [ + '--enable-dart-profiling', + '--trace-allowlist="foo,bar"', + ], + ); + + expect( + logger.errorText, + contains('Error starting debug session in Xcode'), + ); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(status, false); + }); + + testWithoutContext('fails if missing debug results', () async { + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'check-workspace-opened', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":null} + ''', + ), + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'debug', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--device-id', + deviceId, + '--scheme', + project.scheme, + '--skip-building', + '--launch-args', + r'["--enable-dart-profiling","--trace-allowlist=\"foo,bar\""]' + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":null} + ''', + ), + ]); + + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + final bool status = await xcodeDebug.debugApp( + project: project, + deviceId: deviceId, + launchArguments: [ + '--enable-dart-profiling', + '--trace-allowlist="foo,bar"' + ], + ); + + expect( + logger.errorText, + contains('Unable to get debug results from response'), + ); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(status, false); + }); + + testWithoutContext('fails if debug results status is not running', () async { + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'check-workspace-opened', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":null} + ''', + ), + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'debug', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--device-id', + deviceId, + '--scheme', + project.scheme, + '--skip-building', + '--launch-args', + r'["--enable-dart-profiling","--trace-allowlist=\"foo,bar\""]' + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":{"completed":false,"status":"not yet started","errorMessage":null}} + ''', + ), + ]); + + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + final bool status = await xcodeDebug.debugApp( + project: project, + deviceId: deviceId, + launchArguments: [ + '--enable-dart-profiling', + '--trace-allowlist="foo,bar"', + ], + ); + + expect(logger.errorText, contains('Unexpected debug results')); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(status, false); + }); + }); + + group('parse script response', () { + testWithoutContext('fails if osascript output returns non-json output', () async { + final Xcode xcode = setupXcode( + fakeProcessManager: FakeProcessManager.any(), + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + final XcodeAutomationScriptResponse? response = xcodeDebug.parseScriptResponse('not json'); + + expect( + logger.errorText, + contains('osascript returned non-JSON response'), + ); + expect(response, isNull); + }); + + testWithoutContext('fails if osascript output returns unexpected json', () async { + final Xcode xcode = setupXcode( + fakeProcessManager: FakeProcessManager.any(), + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + final XcodeAutomationScriptResponse? response = xcodeDebug.parseScriptResponse('[]'); + + expect( + logger.errorText, + contains('osascript returned unexpected JSON response'), + ); + expect(response, isNull); + }); + + testWithoutContext('fails if osascript output is missing status field', () async { + final Xcode xcode = setupXcode( + fakeProcessManager: FakeProcessManager.any(), + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + final XcodeAutomationScriptResponse? response = xcodeDebug.parseScriptResponse('{}'); + + expect( + logger.errorText, + contains('osascript returned unexpected JSON response'), + ); + expect(response, isNull); + }); + }); + + group('exit', () { + const String pathToXcodeApp = '/Applications/Xcode.app'; + + late Directory projectDirectory; + late Directory xcodeproj; + late Directory xcworkspace; + + setUp(() { + projectDirectory = fileSystem.directory('FlutterApp'); + xcodeproj = projectDirectory.childDirectory('Runner.xcodeproj'); + xcworkspace = projectDirectory.childDirectory('Runner.xcworkspace'); + }); + + testWithoutContext('exits when waiting for debug session to start', () async { + final Xcode xcode = setupXcode( + fakeProcessManager: fakeProcessManager, + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + final XcodeDebugProject project = XcodeDebugProject( + scheme: 'Runner', + xcodeProject: xcodeproj, + xcodeWorkspace: xcworkspace, + ); + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'stop', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":null} + ''', + ), + ]); + + xcodeDebug.startDebugActionProcess = FakeProcess(); + xcodeDebug.currentDebuggingProject = project; + + expect(xcodeDebug.startDebugActionProcess, isNotNull); + expect(xcodeDebug.currentDebuggingProject, isNotNull); + + final bool exitStatus = await xcodeDebug.exit(); + + expect((xcodeDebug.startDebugActionProcess! as FakeProcess).killed, isTrue); + expect(xcodeDebug.currentDebuggingProject, isNull); + expect(logger.errorText, isEmpty); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(exitStatus, isTrue); + }); + + testWithoutContext('exits and deletes temporary directory', () async { + final Xcode xcode = setupXcode( + fakeProcessManager: fakeProcessManager, + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + xcodeproj.createSync(recursive: true); + xcworkspace.createSync(recursive: true); + + final XcodeDebugProject project = XcodeDebugProject( + scheme: 'Runner', + xcodeProject: xcodeproj, + xcodeWorkspace: xcworkspace, + isTemporaryProject: true, + ); + + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'stop', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--close-window' + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":null} + ''', + ), + ]); + + xcodeDebug.startDebugActionProcess = FakeProcess(); + xcodeDebug.currentDebuggingProject = project; + + expect(xcodeDebug.startDebugActionProcess, isNotNull); + expect(xcodeDebug.currentDebuggingProject, isNotNull); + expect(projectDirectory.existsSync(), isTrue); + expect(xcodeproj.existsSync(), isTrue); + expect(xcworkspace.existsSync(), isTrue); + + final bool status = await xcodeDebug.exit(skipDelay: true); + + expect((xcodeDebug.startDebugActionProcess! as FakeProcess).killed, isTrue); + expect(xcodeDebug.currentDebuggingProject, isNull); + expect(projectDirectory.existsSync(), isFalse); + expect(xcodeproj.existsSync(), isFalse); + expect(xcworkspace.existsSync(), isFalse); + expect(logger.errorText, isEmpty); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(status, isTrue); + }); + + testWithoutContext('prints error message when deleting temporary directory that is nonexistant', () async { + final Xcode xcode = setupXcode( + fakeProcessManager: fakeProcessManager, + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + final XcodeDebugProject project = XcodeDebugProject( + scheme: 'Runner', + xcodeProject: xcodeproj, + xcodeWorkspace: xcworkspace, + isTemporaryProject: true, + ); + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'stop', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--close-window' + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":null} + ''', + ), + ]); + + xcodeDebug.startDebugActionProcess = FakeProcess(); + xcodeDebug.currentDebuggingProject = project; + + expect(xcodeDebug.startDebugActionProcess, isNotNull); + expect(xcodeDebug.currentDebuggingProject, isNotNull); + expect(projectDirectory.existsSync(), isFalse); + expect(xcodeproj.existsSync(), isFalse); + expect(xcworkspace.existsSync(), isFalse); + + final bool status = await xcodeDebug.exit(skipDelay: true); + + expect((xcodeDebug.startDebugActionProcess! as FakeProcess).killed, isTrue); + expect(xcodeDebug.currentDebuggingProject, isNull); + expect(projectDirectory.existsSync(), isFalse); + expect(xcodeproj.existsSync(), isFalse); + expect(xcworkspace.existsSync(), isFalse); + expect(logger.errorText, contains('Failed to delete temporary Xcode project')); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(status, isTrue); + }); + + testWithoutContext('kill Xcode when force exit', () async { + final Xcode xcode = setupXcode( + fakeProcessManager: FakeProcessManager.any(), + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + final XcodeDebugProject project = XcodeDebugProject( + scheme: 'Runner', + xcodeProject: xcodeproj, + xcodeWorkspace: xcworkspace, + ); + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + fakeProcessManager.addCommands([ + const FakeCommand( + command: [ + 'killall', + '-9', + 'Xcode', + ], + ), + ]); + + xcodeDebug.startDebugActionProcess = FakeProcess(); + xcodeDebug.currentDebuggingProject = project; + + expect(xcodeDebug.startDebugActionProcess, isNotNull); + expect(xcodeDebug.currentDebuggingProject, isNotNull); + + final bool exitStatus = await xcodeDebug.exit(force: true); + + expect((xcodeDebug.startDebugActionProcess! as FakeProcess).killed, isTrue); + expect(xcodeDebug.currentDebuggingProject, isNull); + expect(logger.errorText, isEmpty); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(exitStatus, isTrue); + }); + + testWithoutContext('does not crash when deleting temporary directory that is nonexistant when force exiting', () async { + final Xcode xcode = setupXcode( + fakeProcessManager: FakeProcessManager.any(), + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + final XcodeDebugProject project = XcodeDebugProject( + scheme: 'Runner', + xcodeProject: xcodeproj, + xcodeWorkspace: xcworkspace, + isTemporaryProject: true, + ); + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager:FakeProcessManager.any(), + xcode: xcode, + fileSystem: fileSystem, + ); + + xcodeDebug.startDebugActionProcess = FakeProcess(); + xcodeDebug.currentDebuggingProject = project; + + expect(xcodeDebug.startDebugActionProcess, isNotNull); + expect(xcodeDebug.currentDebuggingProject, isNotNull); + expect(projectDirectory.existsSync(), isFalse); + expect(xcodeproj.existsSync(), isFalse); + expect(xcworkspace.existsSync(), isFalse); + + final bool status = await xcodeDebug.exit(force: true); + + expect((xcodeDebug.startDebugActionProcess! as FakeProcess).killed, isTrue); + expect(xcodeDebug.currentDebuggingProject, isNull); + expect(projectDirectory.existsSync(), isFalse); + expect(xcodeproj.existsSync(), isFalse); + expect(xcworkspace.existsSync(), isFalse); + expect(logger.errorText, isEmpty); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(status, isTrue); + }); + }); + + group('stop app', () { + const String pathToXcodeApp = '/Applications/Xcode.app'; + + late Xcode xcode; + late Directory xcodeproj; + late Directory xcworkspace; + late XcodeDebugProject project; + + setUp(() { + xcode = setupXcode( + fakeProcessManager: fakeProcessManager, + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + xcodeproj = fileSystem.directory('Runner.xcodeproj'); + xcworkspace = fileSystem.directory('Runner.xcworkspace'); + project = XcodeDebugProject( + scheme: 'Runner', + xcodeProject: xcodeproj, + xcodeWorkspace: xcworkspace, + ); + }); + + testWithoutContext('succeeds with all optional flags', () async { + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'stop', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--close-window', + '--prompt-to-save' + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":null} + ''', + ), + ]); + + final bool status = await xcodeDebug.stopDebuggingApp( + project: project, + closeXcode: true, + promptToSaveOnClose: true, + ); + + expect(logger.errorText, isEmpty); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(status, isTrue); + }); + + testWithoutContext('fails if osascript output returns false status', () async { + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'stop', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--close-window', + '--prompt-to-save' + ], + stdout: ''' + {"status":false,"errorMessage":"Failed to stop app","debugResult":null} + ''', + ), + ]); + + final bool status = await xcodeDebug.stopDebuggingApp( + project: project, + closeXcode: true, + promptToSaveOnClose: true, + ); + + expect(logger.errorText, contains('Error stopping app in Xcode')); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(status, isFalse); + }); + }); + }); + + group('Debug project through Xcode with app bundle', () { + late BufferLogger logger; + late FakeProcessManager fakeProcessManager; + late MemoryFileSystem fileSystem; + + const String flutterRoot = '/path/to/flutter'; + + setUp(() { + logger = BufferLogger.test(); + fakeProcessManager = FakeProcessManager.empty(); + fileSystem = MemoryFileSystem.test(); + }); + + testUsingContext('creates temporary xcode project', () async { + final Xcode xcode = setupXcode( + fakeProcessManager: fakeProcessManager, + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: globals.fs, + ); + + final Directory projectDirectory = globals.fs.systemTempDirectory.createTempSync('flutter_empty_xcode.'); + + try { + final XcodeDebugProject project = await xcodeDebug.createXcodeProjectWithCustomBundle( + '/path/to/bundle', + templateRenderer: globals.templateRenderer, + projectDestination: projectDirectory, + ); + + final File schemeFile = projectDirectory + .childDirectory('Runner.xcodeproj') + .childDirectory('xcshareddata') + .childDirectory('xcschemes') + .childFile('Runner.xcscheme'); + + expect(project.scheme, 'Runner'); + expect(project.xcodeProject.existsSync(), isTrue); + expect(project.xcodeWorkspace.existsSync(), isTrue); + expect(project.isTemporaryProject, isTrue); + expect(projectDirectory.childDirectory('Runner.xcodeproj').existsSync(), isTrue); + expect(projectDirectory.childDirectory('Runner.xcworkspace').existsSync(), isTrue); + expect(schemeFile.existsSync(), isTrue); + expect(schemeFile.readAsStringSync(), contains('FilePath = "/path/to/bundle"')); + + } catch (err) { // ignore: avoid_catches_without_on_clauses + fail(err.toString()); + } finally { + projectDirectory.deleteSync(recursive: true); + } + }); + }); +} + +Xcode setupXcode({ + required FakeProcessManager fakeProcessManager, + required FileSystem fileSystem, + required String flutterRoot, + bool xcodeSelect = true, +}) { + fakeProcessManager.addCommand(const FakeCommand( + command: ['/usr/bin/xcode-select', '--print-path'], + stdout: '/Applications/Xcode.app/Contents/Developer', + )); + + fileSystem.file('$flutterRoot/packages/flutter_tools/bin/xcode_debug.js').createSync(recursive: true); + + final XcodeProjectInterpreter xcodeProjectInterpreter = XcodeProjectInterpreter.test( + processManager: FakeProcessManager.any(), + version: Version(14, 0, 0), + ); + + return Xcode.test( + processManager: fakeProcessManager, + xcodeProjectInterpreter: xcodeProjectInterpreter, + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); +} + +class FakeProcess extends Fake implements Process { + bool killed = false; + + @override + bool kill([io.ProcessSignal signal = io.ProcessSignal.sigterm]) { + killed = true; + return true; + } +} diff --git a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart index 90210c3255604..3dfe9157d5189 100644 --- a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart @@ -1306,5 +1306,75 @@ flutter: expectedBuildNumber: '1', ); }); + + group('CoreDevice', () { + testUsingContext('sets BUILD_DIR for core devices in debug mode', () async { + const BuildInfo buildInfo = BuildInfo.debug; + final FlutterProject project = FlutterProject.fromDirectoryTest(fs.directory('path/to/project')); + await updateGeneratedXcodeProperties( + project: project, + buildInfo: buildInfo, + useMacOSConfig: true, + usingCoreDevice: true, + ); + + final File config = fs.file('path/to/project/macos/Flutter/ephemeral/Flutter-Generated.xcconfig'); + expect(config.existsSync(), isTrue); + + final String contents = config.readAsStringSync(); + expect(contents, contains('\nBUILD_DIR=/build/ios\n')); + }, overrides: { + Artifacts: () => localIosArtifacts, + Platform: () => macOS, + FileSystem: () => fs, + ProcessManager: () => FakeProcessManager.any(), + XcodeProjectInterpreter: () => xcodeProjectInterpreter, + }); + + testUsingContext('does not set BUILD_DIR for core devices in release mode', () async { + const BuildInfo buildInfo = BuildInfo.release; + final FlutterProject project = FlutterProject.fromDirectoryTest(fs.directory('path/to/project')); + await updateGeneratedXcodeProperties( + project: project, + buildInfo: buildInfo, + useMacOSConfig: true, + usingCoreDevice: true, + ); + + final File config = fs.file('path/to/project/macos/Flutter/ephemeral/Flutter-Generated.xcconfig'); + expect(config.existsSync(), isTrue); + + final String contents = config.readAsStringSync(); + expect(contents.contains('\nBUILD_DIR'), isFalse); + }, overrides: { + Artifacts: () => localIosArtifacts, + Platform: () => macOS, + FileSystem: () => fs, + ProcessManager: () => FakeProcessManager.any(), + XcodeProjectInterpreter: () => xcodeProjectInterpreter, + }); + + testUsingContext('does not set BUILD_DIR for non core devices', () async { + const BuildInfo buildInfo = BuildInfo.debug; + final FlutterProject project = FlutterProject.fromDirectoryTest(fs.directory('path/to/project')); + await updateGeneratedXcodeProperties( + project: project, + buildInfo: buildInfo, + useMacOSConfig: true, + ); + + final File config = fs.file('path/to/project/macos/Flutter/ephemeral/Flutter-Generated.xcconfig'); + expect(config.existsSync(), isTrue); + + final String contents = config.readAsStringSync(); + expect(contents.contains('\nBUILD_DIR'), isFalse); + }, overrides: { + Artifacts: () => localIosArtifacts, + Platform: () => macOS, + FileSystem: () => fs, + ProcessManager: () => FakeProcessManager.any(), + XcodeProjectInterpreter: () => xcodeProjectInterpreter, + }); + }); }); } diff --git a/packages/flutter_tools/test/general.shard/macos/xcode_test.dart b/packages/flutter_tools/test/general.shard/macos/xcode_test.dart index 887712ad2e5c3..6df6b82b6d751 100644 --- a/packages/flutter_tools/test/general.shard/macos/xcode_test.dart +++ b/packages/flutter_tools/test/general.shard/macos/xcode_test.dart @@ -4,16 +4,20 @@ import 'dart:async'; +import 'package:file/memory.dart'; import 'package:flutter_tools/src/artifacts.dart'; import 'package:flutter_tools/src/base/io.dart' show ProcessException; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/platform.dart'; +import 'package:flutter_tools/src/base/user_messages.dart'; import 'package:flutter_tools/src/base/version.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/device.dart'; +import 'package:flutter_tools/src/ios/core_devices.dart'; import 'package:flutter_tools/src/ios/devices.dart'; import 'package:flutter_tools/src/ios/iproxy.dart'; +import 'package:flutter_tools/src/ios/xcode_debug.dart'; import 'package:flutter_tools/src/ios/xcodeproj.dart'; import 'package:flutter_tools/src/macos/xcdevice.dart'; import 'package:flutter_tools/src/macos/xcode.dart'; @@ -75,7 +79,7 @@ void main() { expect(fakeProcessManager, hasNoRemainingExpectations); }); - testWithoutContext('isSimctlInstalled is true when simctl list fails', () { + testWithoutContext('isSimctlInstalled is false when simctl list fails', () { fakeProcessManager.addCommand( const FakeCommand( command: [ @@ -97,6 +101,156 @@ void main() { expect(fakeProcessManager, hasNoRemainingExpectations); }); + group('isDevicectlInstalled', () { + testWithoutContext('is true when Xcode is 15+ and devicectl succeeds', () { + fakeProcessManager.addCommand( + const FakeCommand( + command: [ + 'xcrun', + 'devicectl', + '--version', + ], + ), + ); + xcodeProjectInterpreter.version = Version(15, 0, 0); + final Xcode xcode = Xcode.test( + processManager: fakeProcessManager, + xcodeProjectInterpreter: xcodeProjectInterpreter, + ); + + expect(xcode.isDevicectlInstalled, isTrue); + expect(fakeProcessManager, hasNoRemainingExpectations); + }); + + testWithoutContext('is false when devicectl fails', () { + fakeProcessManager.addCommand( + const FakeCommand( + command: [ + 'xcrun', + 'devicectl', + '--version', + ], + exitCode: 1, + ), + ); + xcodeProjectInterpreter.version = Version(15, 0, 0); + final Xcode xcode = Xcode.test( + processManager: fakeProcessManager, + xcodeProjectInterpreter: xcodeProjectInterpreter, + ); + + expect(xcode.isDevicectlInstalled, isFalse); + expect(fakeProcessManager, hasNoRemainingExpectations); + }); + + testWithoutContext('is false when Xcode is less than 15', () { + xcodeProjectInterpreter.version = Version(14, 0, 0); + final Xcode xcode = Xcode.test( + processManager: fakeProcessManager, + xcodeProjectInterpreter: xcodeProjectInterpreter, + ); + + expect(xcode.isDevicectlInstalled, isFalse); + expect(fakeProcessManager, hasNoRemainingExpectations); + }); + }); + + group('pathToXcodeApp', () { + late UserMessages userMessages; + + setUp(() { + userMessages = UserMessages(); + }); + + testWithoutContext('parses correctly', () { + final Xcode xcode = Xcode.test( + processManager: fakeProcessManager, + xcodeProjectInterpreter: xcodeProjectInterpreter, + ); + + fakeProcessManager.addCommand(const FakeCommand( + command: ['/usr/bin/xcode-select', '--print-path'], + stdout: '/Applications/Xcode.app/Contents/Developer', + )); + + expect(xcode.xcodeAppPath, '/Applications/Xcode.app'); + expect(fakeProcessManager, hasNoRemainingExpectations); + }); + + testWithoutContext('throws error if not found', () { + final Xcode xcode = Xcode.test( + processManager: FakeProcessManager.any(), + xcodeProjectInterpreter: xcodeProjectInterpreter, + ); + + expect( + () => xcode.xcodeAppPath, + throwsToolExit(message: userMessages.xcodeMissing), + ); + }); + + testWithoutContext('throws error with unexpected outcome', () { + final Xcode xcode = Xcode.test( + processManager: fakeProcessManager, + xcodeProjectInterpreter: xcodeProjectInterpreter, + ); + + fakeProcessManager.addCommand(const FakeCommand( + command: [ + '/usr/bin/xcode-select', + '--print-path', + ], + stdout: '/Library/Developer/CommandLineTools', + )); + + expect( + () => xcode.xcodeAppPath, + throwsToolExit(message: userMessages.xcodeMissing), + ); + expect(fakeProcessManager, hasNoRemainingExpectations); + }); + }); + + group('pathToXcodeAutomationScript', () { + const String flutterRoot = '/path/to/flutter'; + + late MemoryFileSystem fileSystem; + + setUp(() { + fileSystem = MemoryFileSystem.test(); + }); + + testWithoutContext('returns path when file is found', () { + final Xcode xcode = Xcode.test( + processManager: fakeProcessManager, + xcodeProjectInterpreter: xcodeProjectInterpreter, + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + + fileSystem.file('$flutterRoot/packages/flutter_tools/bin/xcode_debug.js').createSync(recursive: true); + + expect( + xcode.xcodeAutomationScriptPath, + '$flutterRoot/packages/flutter_tools/bin/xcode_debug.js', + ); + }); + + testWithoutContext('throws error when not found', () { + final Xcode xcode = Xcode.test( + processManager: fakeProcessManager, + xcodeProjectInterpreter: xcodeProjectInterpreter, + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + + expect(() => + xcode.xcodeAutomationScriptPath, + throwsToolExit() + ); + }); + }); + group('macOS', () { late Xcode xcode; late BufferLogger logger; @@ -339,6 +493,7 @@ void main() { group('xcdevice not installed', () { late XCDevice xcdevice; late Xcode xcode; + late MemoryFileSystem fileSystem; setUp(() { xcode = Xcode.test( @@ -348,6 +503,7 @@ void main() { version: null, // Not installed. ), ); + fileSystem = MemoryFileSystem.test(); xcdevice = XCDevice( processManager: fakeProcessManager, logger: logger, @@ -356,6 +512,9 @@ void main() { artifacts: Artifacts.test(), cache: Cache.test(processManager: FakeProcessManager.any()), iproxy: IProxy.test(logger: logger, processManager: fakeProcessManager), + fileSystem: fileSystem, + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug(), ); }); @@ -373,9 +532,13 @@ void main() { group('xcdevice', () { late XCDevice xcdevice; late Xcode xcode; + late MemoryFileSystem fileSystem; + late FakeIOSCoreDeviceControl coreDeviceControl; setUp(() { xcode = Xcode.test(processManager: FakeProcessManager.any()); + fileSystem = MemoryFileSystem.test(); + coreDeviceControl = FakeIOSCoreDeviceControl(); xcdevice = XCDevice( processManager: fakeProcessManager, logger: logger, @@ -384,6 +547,9 @@ void main() { artifacts: Artifacts.test(), cache: Cache.test(processManager: FakeProcessManager.any()), iproxy: IProxy.test(logger: logger, processManager: fakeProcessManager), + fileSystem: fileSystem, + coreDeviceControl: coreDeviceControl, + xcodeDebug: FakeXcodeDebug(), ); }); @@ -1117,6 +1283,176 @@ void main() { }, overrides: { Platform: () => macPlatform, }); + + group('with CoreDevices', () { + testUsingContext('returns devices with corresponding CoreDevices', () async { + const String devicesOutput = ''' +[ + { + "simulator" : true, + "operatingSystemVersion" : "13.3 (17K446)", + "available" : true, + "platform" : "com.apple.platform.appletvsimulator", + "modelCode" : "AppleTV5,3", + "identifier" : "CBB5E1ED-2172-446E-B4E7-F2B5823DBBA6", + "architecture" : "x86_64", + "modelName" : "Apple TV", + "name" : "Apple TV" + }, + { + "simulator" : false, + "operatingSystemVersion" : "13.3 (17C54)", + "interface" : "usb", + "available" : true, + "platform" : "com.apple.platform.iphoneos", + "modelCode" : "iPhone8,1", + "identifier" : "00008027-00192736010F802E", + "architecture" : "arm64", + "modelName" : "iPhone 6s", + "name" : "An iPhone (Space Gray)" + }, + { + "simulator" : false, + "operatingSystemVersion" : "10.1 (14C54)", + "interface" : "usb", + "available" : true, + "platform" : "com.apple.platform.iphoneos", + "modelCode" : "iPad11,4", + "identifier" : "98206e7a4afd4aedaff06e687594e089dede3c44", + "architecture" : "armv7", + "modelName" : "iPad Air 3rd Gen", + "name" : "iPad 1" + }, + { + "simulator" : false, + "operatingSystemVersion" : "10.1 (14C54)", + "interface" : "network", + "available" : true, + "platform" : "com.apple.platform.iphoneos", + "modelCode" : "iPad11,4", + "identifier" : "234234234234234234345445687594e089dede3c44", + "architecture" : "arm64", + "modelName" : "iPad Air 3rd Gen", + "name" : "A networked iPad" + }, + { + "simulator" : false, + "operatingSystemVersion" : "10.1 (14C54)", + "interface" : "usb", + "available" : true, + "platform" : "com.apple.platform.iphoneos", + "modelCode" : "iPad11,4", + "identifier" : "f577a7903cc54959be2e34bc4f7f80b7009efcf4", + "architecture" : "BOGUS", + "modelName" : "iPad Air 3rd Gen", + "name" : "iPad 2" + }, + { + "simulator" : true, + "operatingSystemVersion" : "6.1.1 (17S445)", + "available" : true, + "platform" : "com.apple.platform.watchsimulator", + "modelCode" : "Watch5,4", + "identifier" : "2D74FB11-88A0-44D0-B81E-C0C142B1C94A", + "architecture" : "i386", + "modelName" : "Apple Watch Series 5 - 44mm", + "name" : "Apple Watch Series 5 - 44mm" + }, + { + "simulator" : false, + "operatingSystemVersion" : "13.3 (17C54)", + "interface" : "usb", + "available" : false, + "platform" : "com.apple.platform.iphoneos", + "modelCode" : "iPhone8,1", + "identifier" : "c4ca6f7a53027d1b7e4972e28478e7a28e2faee2", + "architecture" : "arm64", + "modelName" : "iPhone 6s", + "name" : "iPhone", + "error" : { + "code" : -9, + "failureReason" : "", + "description" : "iPhone is not paired with your computer.", + "domain" : "com.apple.platform.iphoneos" + } + } +] +'''; + coreDeviceControl.devices.addAll([ + FakeIOSCoreDevice( + udid: '00008027-00192736010F802E', + connectionInterface: DeviceConnectionInterface.wireless, + developerModeStatus: 'enabled', + ), + FakeIOSCoreDevice( + connectionInterface: DeviceConnectionInterface.wireless, + developerModeStatus: 'enabled', + ), + FakeIOSCoreDevice( + udid: '234234234234234234345445687594e089dede3c44', + connectionInterface: DeviceConnectionInterface.attached, + ), + FakeIOSCoreDevice( + udid: 'f577a7903cc54959be2e34bc4f7f80b7009efcf4', + connectionInterface: DeviceConnectionInterface.attached, + developerModeStatus: 'disabled', + ), + ]); + + fakeProcessManager.addCommand(const FakeCommand( + command: ['xcrun', 'xcdevice', 'list', '--timeout', '2'], + stdout: devicesOutput, + )); + + final List devices = await xcdevice.getAvailableIOSDevices(); + expect(devices, hasLength(5)); + expect(devices[0].id, '00008027-00192736010F802E'); + expect(devices[0].name, 'An iPhone (Space Gray)'); + expect(await devices[0].sdkNameAndVersion, 'iOS 13.3 17C54'); + expect(devices[0].cpuArchitecture, DarwinArch.arm64); + expect(devices[0].connectionInterface, DeviceConnectionInterface.wireless); + expect(devices[0].isConnected, true); + expect(devices[0].devModeEnabled, true); + + expect(devices[1].id, '98206e7a4afd4aedaff06e687594e089dede3c44'); + expect(devices[1].name, 'iPad 1'); + expect(await devices[1].sdkNameAndVersion, 'iOS 10.1 14C54'); + expect(devices[1].cpuArchitecture, DarwinArch.armv7); + expect(devices[1].connectionInterface, DeviceConnectionInterface.attached); + expect(devices[1].isConnected, true); + expect(devices[1].devModeEnabled, true); + + expect(devices[2].id, '234234234234234234345445687594e089dede3c44'); + expect(devices[2].name, 'A networked iPad'); + expect(await devices[2].sdkNameAndVersion, 'iOS 10.1 14C54'); + expect(devices[2].cpuArchitecture, DarwinArch.arm64); // Defaults to arm64 for unknown architecture. + expect(devices[2].connectionInterface, DeviceConnectionInterface.attached); + expect(devices[2].isConnected, true); + expect(devices[2].devModeEnabled, false); + + expect(devices[3].id, 'f577a7903cc54959be2e34bc4f7f80b7009efcf4'); + expect(devices[3].name, 'iPad 2'); + expect(await devices[3].sdkNameAndVersion, 'iOS 10.1 14C54'); + expect(devices[3].cpuArchitecture, DarwinArch.arm64); // Defaults to arm64 for unknown architecture. + expect(devices[3].connectionInterface, DeviceConnectionInterface.attached); + expect(devices[3].isConnected, true); + expect(devices[3].devModeEnabled, false); + + expect(devices[4].id, 'c4ca6f7a53027d1b7e4972e28478e7a28e2faee2'); + expect(devices[4].name, 'iPhone'); + expect(await devices[4].sdkNameAndVersion, 'iOS 13.3 17C54'); + expect(devices[4].cpuArchitecture, DarwinArch.arm64); + expect(devices[4].connectionInterface, DeviceConnectionInterface.attached); + expect(devices[4].isConnected, false); + expect(devices[4].devModeEnabled, true); + + expect(fakeProcessManager, hasNoRemainingExpectations); + }, overrides: { + Platform: () => macPlatform, + Artifacts: () => Artifacts.test(), + }); + + }); }); group('diagnostics', () { @@ -1312,3 +1648,41 @@ class FakeXcodeProjectInterpreter extends Fake implements XcodeProjectInterprete @override List xcrunCommand() => ['xcrun']; } + +class FakeXcodeDebug extends Fake implements XcodeDebug {} + +class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl { + + List devices = []; + + @override + Future> getCoreDevices({Duration timeout = Duration.zero}) async { + return devices; + } +} + +class FakeIOSCoreDevice extends Fake implements IOSCoreDevice { + FakeIOSCoreDevice({ + this.udid, + this.connectionInterface, + this.developerModeStatus, + }); + + final String? developerModeStatus; + + @override + final String? udid; + + @override + final DeviceConnectionInterface? connectionInterface; + + @override + IOSCoreDeviceProperties? get deviceProperties => FakeIOSCoreDeviceProperties(developerModeStatus: developerModeStatus); +} + +class FakeIOSCoreDeviceProperties extends Fake implements IOSCoreDeviceProperties { + FakeIOSCoreDeviceProperties({required this.developerModeStatus}); + + @override + final String? developerModeStatus; +} diff --git a/packages/flutter_tools/test/general.shard/mdns_discovery_test.dart b/packages/flutter_tools/test/general.shard/mdns_discovery_test.dart index b0f608538753e..8c6868512138d 100644 --- a/packages/flutter_tools/test/general.shard/mdns_discovery_test.dart +++ b/packages/flutter_tools/test/general.shard/mdns_discovery_test.dart @@ -478,6 +478,18 @@ void main() { }); group('for launch', () { + testWithoutContext('Ensure either port or device name are provided', () async { + final MDnsClient client = FakeMDnsClient([], >{}); + + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + + expect(() async => portDiscovery.queryForLaunch(applicationId: 'app-id'), throwsAssertionError); + }); + testWithoutContext('No ports available', () async { final MDnsClient client = FakeMDnsClient([], >{}); @@ -666,6 +678,93 @@ void main() { message:'Did not find a Dart VM Service advertised for srv-bar on port 321.'), ); }); + + testWithoutContext('Matches on application id and device name', () async { + final MDnsClient client = FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'srv-foo'), + PtrResourceRecord('bar', future, domainName: 'srv-bar'), + PtrResourceRecord('baz', future, domainName: 'srv-boo'), + ], + >{ + 'srv-bar': [ + SrvResourceRecord('srv-foo', future, port: 123, weight: 1, priority: 1, target: 'My-Phone.local'), + ], + }, + ); + final FakeIOSDevice device = FakeIOSDevice( + name: 'My Phone', + ); + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + + final Uri? uri = await portDiscovery.getVMServiceUriForLaunch( + 'srv-bar', + device, + ); + expect(uri.toString(), 'http://127.0.0.1:123/'); + }); + + testWithoutContext('Throw error if unable to find VM Service with app id and device name', () async { + final MDnsClient client = FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'srv-foo'), + PtrResourceRecord('bar', future, domainName: 'srv-bar'), + PtrResourceRecord('baz', future, domainName: 'srv-boo'), + ], + >{ + 'srv-foo': [ + SrvResourceRecord('srv-foo', future, port: 123, weight: 1, priority: 1, target: 'target-foo'), + ], + }, + ); + final FakeIOSDevice device = FakeIOSDevice( + name: 'My Phone', + ); + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + expect( + portDiscovery.getVMServiceUriForLaunch( + 'srv-bar', + device, + ), + throwsToolExit( + message:'Did not find a Dart VM Service advertised for srv-bar'), + ); + }); + }); + + group('deviceNameMatchesTargetName', () { + testWithoutContext('compares case insensitive and without spaces, hypthens, .local', () { + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: FakeMDnsClient( + [], + >{}, + ), + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + + expect(portDiscovery.deviceNameMatchesTargetName('My phone', 'My-Phone.local'), isTrue); + }); + + testWithoutContext('includes numbers in comparison', () { + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: FakeMDnsClient( + [], + >{}, + ), + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + expect(portDiscovery.deviceNameMatchesTargetName('My phone', 'My-Phone-2.local'), isFalse); + }); }); testWithoutContext('Find firstMatchingVmService with many available and no application id', () async { @@ -895,6 +994,11 @@ class FakeMDnsClient extends Fake implements MDnsClient { // Until we fix that, we have to also ignore related lints here. // ignore: avoid_implementing_value_types class FakeIOSDevice extends Fake implements IOSDevice { + FakeIOSDevice({this.name = 'iPhone'}); + + @override + final String name; + @override Future get targetPlatform async => TargetPlatform.ios; From ff8ade8b77a3751d26d9c516690e7dd4f3547dce Mon Sep 17 00:00:00 2001 From: Victoria Ashworth <15619084+vashworth@users.noreply.github.com> Date: Mon, 14 Aug 2023 16:04:09 -0500 Subject: [PATCH 16/58] [CP] Print pretty error when xcodebuild fails due to missing simulator (#132235) Original PR: https://github.com/flutter/flutter/pull/130506 Note: I added a commit to fix a name discrepancy. [A commit on master](https://github.com/flutter/flutter/pull/130156/files#diff-157aa748cf5e84d2d03141150ddf4136e24635f1a1d50fc52e1da2354a70ac83L1043), that was added prior to the CP PR, renamed a variable from `_xcBundleFilePath` -> `_xcBundleDirectoryPath`. The CP PR uses the new name since it came after the first commit, so I added a commit to this PR to use the old variable name. --- packages/flutter_tools/lib/src/ios/mac.dart | 50 +++- .../flutter_tools/lib/src/ios/xcresult.dart | 88 +++++++ .../hermetic/build_ios_test.dart | 31 +++ .../test/general.shard/ios/mac_test.dart | 38 +++ .../test/general.shard/ios/xcresult_test.dart | 13 + .../general.shard/ios/xcresult_test_data.dart | 249 ++++++++++++++++++ 6 files changed, 468 insertions(+), 1 deletion(-) diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart index ecca27b24f8a5..d8d9188109eb5 100644 --- a/packages/flutter_tools/lib/src/ios/mac.dart +++ b/packages/flutter_tools/lib/src/ios/mac.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:meta/meta.dart'; import 'package:process/process.dart'; import '../artifacts.dart'; @@ -41,6 +42,21 @@ import 'xcresult.dart'; const String kConcurrentRunFailureMessage1 = 'database is locked'; const String kConcurrentRunFailureMessage2 = 'there are two concurrent builds running'; +/// User message when missing platform required to use Xcode. +/// +/// Starting with Xcode 15, the simulator is no longer downloaded with Xcode +/// and must be downloaded and installed separately. +@visibleForTesting +String missingPlatformInstructions(String simulatorVersion) => ''' +โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +$simulatorVersion is not installed. To download and install the platform, open +Xcode, select Xcode > Settings > Platforms, and click the GET button for the +required platform. + +For more information, please visit: + https://developer.apple.com/documentation/xcode/installing-additional-simulator-runtimes +โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'''; + class IMobileDevice { IMobileDevice({ required Artifacts artifacts, @@ -702,6 +718,11 @@ _XCResultIssueHandlingResult _handleXCResultIssue({required XCResultIssue issue, return _XCResultIssueHandlingResult(requiresProvisioningProfile: true, hasProvisioningProfileIssue: true); } else if (message.toLowerCase().contains('provisioning profile')) { return _XCResultIssueHandlingResult(requiresProvisioningProfile: false, hasProvisioningProfileIssue: true); + } else if (message.toLowerCase().contains('ineligible destinations')) { + final String? missingPlatform = _parseMissingPlatform(message); + if (missingPlatform != null) { + return _XCResultIssueHandlingResult(requiresProvisioningProfile: false, hasProvisioningProfileIssue: false, missingPlatform: missingPlatform); + } } return _XCResultIssueHandlingResult(requiresProvisioningProfile: false, hasProvisioningProfileIssue: false); } @@ -711,6 +732,7 @@ bool _handleIssues(XCResult? xcResult, Logger logger, XcodeBuildExecution? xcode bool requiresProvisioningProfile = false; bool hasProvisioningProfileIssue = false; bool issueDetected = false; + String? missingPlatform; if (xcResult != null && xcResult.parseSuccess) { for (final XCResultIssue issue in xcResult.issues) { @@ -721,6 +743,7 @@ bool _handleIssues(XCResult? xcResult, Logger logger, XcodeBuildExecution? xcode if (handlingResult.requiresProvisioningProfile) { requiresProvisioningProfile = true; } + missingPlatform = handlingResult.missingPlatform; issueDetected = true; } } else if (xcResult != null) { @@ -740,6 +763,8 @@ bool _handleIssues(XCResult? xcResult, Logger logger, XcodeBuildExecution? xcode logger.printError(' open ios/Runner.xcworkspace'); logger.printError(''); logger.printError("Also try selecting 'Product > Build' to fix the problem."); + } else if (missingPlatform != null) { + logger.printError(missingPlatformInstructions(missingPlatform), emphasis: true); } return issueDetected; @@ -775,18 +800,41 @@ void _parseIssueInStdout(XcodeBuildExecution xcodeBuildExecution, Logger logger, && (result.stdout?.contains('requires a provisioning profile. Select a provisioning profile in the Signing & Capabilities editor') ?? false)) { logger.printError(noProvisioningProfileInstruction, emphasis: true); } + + if (stderr != null && stderr.contains('Ineligible destinations')) { + final String? version = _parseMissingPlatform(stderr); + if (version != null) { + logger.printError(missingPlatformInstructions(version), emphasis: true); + } + } +} + +String? _parseMissingPlatform(String message) { + final RegExp pattern = RegExp(r'error:(.*?) is not installed\. To use with Xcode, first download and install the platform'); + final RegExpMatch? match = pattern.firstMatch(message); + if (match != null) { + final String? version = match.group(1); + return version; + } + return null; } // The result of [_handleXCResultIssue]. class _XCResultIssueHandlingResult { - _XCResultIssueHandlingResult({required this.requiresProvisioningProfile, required this.hasProvisioningProfileIssue}); + _XCResultIssueHandlingResult({ + required this.requiresProvisioningProfile, + required this.hasProvisioningProfileIssue, + this.missingPlatform, + }); // An issue indicates that user didn't provide the provisioning profile. final bool requiresProvisioningProfile; // An issue indicates that there is a provisioning profile issue. final bool hasProvisioningProfileIssue; + + final String? missingPlatform; } const String _kResultBundlePath = 'temporary_xcresult_bundle'; diff --git a/packages/flutter_tools/lib/src/ios/xcresult.dart b/packages/flutter_tools/lib/src/ios/xcresult.dart index 5bb520e0a5046..1329d6b3bd754 100644 --- a/packages/flutter_tools/lib/src/ios/xcresult.dart +++ b/packages/flutter_tools/lib/src/ios/xcresult.dart @@ -104,6 +104,13 @@ class XCResult { issueDiscarder: issueDiscarders, )); } + + final Object? actionsMap = resultJson['actions']; + if (actionsMap is Map) { + final List actionIssues = _parseActionIssues(actionsMap, issueDiscarders: issueDiscarders); + issues.addAll(actionIssues); + } + return XCResult._(issues: issues); } @@ -383,3 +390,84 @@ List _parseIssuesFromIssueSummariesJson({ } return issues; } + +List _parseActionIssues( + Map actionsMap, { + required List issueDiscarders, +}) { + // Example of json: + // { + // "actions" : { + // "_values" : [ + // { + // "actionResult" : { + // "_type" : { + // "_name" : "ActionResult" + // }, + // "issues" : { + // "_type" : { + // "_name" : "ResultIssueSummaries" + // }, + // "testFailureSummaries" : { + // "_type" : { + // "_name" : "Array" + // }, + // "_values" : [ + // { + // "_type" : { + // "_name" : "TestFailureIssueSummary", + // "_supertype" : { + // "_name" : "IssueSummary" + // } + // }, + // "issueType" : { + // "_type" : { + // "_name" : "String" + // }, + // "_value" : "Uncategorized" + // }, + // "message" : { + // "_type" : { + // "_name" : "String" + // }, + // "_value" : "Unable to find a destination matching the provided destination specifier:\n\t\t{ id:1234D567-890C-1DA2-34E5-F6789A0123C4 }\n\n\tIneligible destinations for the \"Runner\" scheme:\n\t\t{ platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device, error:iOS 17.0 is not installed. To use with Xcode, first download and install the platform }" + // } + // } + // ] + // } + // } + // } + // } + // ] + // } + // } + final List issues = []; + final Object? actionsValues = actionsMap['_values']; + if (actionsValues is! List) { + return issues; + } + + for (final Object? actionValue in actionsValues) { + if (actionValue is!Map) { + continue; + } + final Object? actionResult = actionValue['actionResult']; + if (actionResult is! Map) { + continue; + } + final Object? actionResultIssues = actionResult['issues']; + if (actionResultIssues is! Map) { + continue; + } + final Object? testFailureSummaries = actionResultIssues['testFailureSummaries']; + if (testFailureSummaries is Map) { + issues.addAll(_parseIssuesFromIssueSummariesJson( + type: XCResultIssueType.error, + issueSummariesJson: testFailureSummaries, + issueDiscarder: issueDiscarders, + )); + } + } + + return issues; + } diff --git a/packages/flutter_tools/test/commands.shard/hermetic/build_ios_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/build_ios_test.dart index aaf95811246c6..83756298bbaa3 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/build_ios_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/build_ios_test.dart @@ -638,6 +638,37 @@ void main() { XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), }); + testUsingContext('Extra error message for missing simulator platform in xcresult bundle.', () async { + final BuildCommand command = BuildCommand( + androidSdk: FakeAndroidSdk(), + buildSystem: TestBuildSystem.all(BuildResult(success: true)), + fileSystem: MemoryFileSystem.test(), + logger: BufferLogger.test(), + osUtils: FakeOperatingSystemUtils(), + ); + + createMinimalMockProjectFiles(); + + await expectLater( + createTestCommandRunner(command).run(const ['build', 'ios', '--no-pub']), + throwsToolExit(), + ); + + expect(testLogger.errorText, contains(missingPlatformInstructions('iOS 17.0'))); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.list([ + xattrCommand, + setUpFakeXcodeBuildHandler(exitCode: 1, onRun: () { + fileSystem.systemTempDirectory.childDirectory(_xcBundleFilePath).createSync(); + }), + setUpXCResultCommand(stdout: kSampleResultJsonWithActionIssues), + setUpRsyncCommand(), + ]), + Platform: () => macosPlatform, + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + }); + testUsingContext('Delete xcresult bundle before each xcodebuild command.', () async { final BuildCommand command = BuildCommand( androidSdk: FakeAndroidSdk(), diff --git a/packages/flutter_tools/test/general.shard/ios/mac_test.dart b/packages/flutter_tools/test/general.shard/ios/mac_test.dart index e7fef592be5f1..303d261273ee7 100644 --- a/packages/flutter_tools/test/general.shard/ios/mac_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/mac_test.dart @@ -245,6 +245,44 @@ Error launching application on iPhone.''', ); }); + testWithoutContext('fallback to stdout: Ineligible destinations', () async { + final Map buildSettingsWithDevTeam = { + 'PRODUCT_BUNDLE_IDENTIFIER': 'test.app', + 'DEVELOPMENT_TEAM': 'a team', + }; + final XcodeBuildResult buildResult = XcodeBuildResult( + success: false, + stderr: ''' +Launching lib/main.dart on iPhone in debug mode... +Signing iOS app for device deployment using developer identity: "iPhone Developer: test@flutter.io (1122334455)" +Running Xcode build... 1.3s +Failed to build iOS app +Error output from Xcode build: +โ†ณ + xcodebuild: error: Unable to find a destination matching the provided destination specifier: + { id:1234D567-890C-1DA2-34E5-F6789A0123C4 } + + Ineligible destinations for the "Runner" scheme: + { platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device, error:iOS 17.0 is not installed. To use with Xcode, first download and install the platform } + +Could not build the precompiled application for the device. + +Error launching application on iPhone.''', + xcodeBuildExecution: XcodeBuildExecution( + buildCommands: ['xcrun', 'xcodebuild', 'blah'], + appDirectory: '/blah/blah', + environmentType: EnvironmentType.physical, + buildSettings: buildSettingsWithDevTeam, + ), + ); + + await diagnoseXcodeBuildFailure(buildResult, testUsage, logger); + expect( + logger.errorText, + contains(missingPlatformInstructions('iOS 17.0')), + ); + }); + testWithoutContext('No development team shows message', () async { final XcodeBuildResult buildResult = XcodeBuildResult( success: false, diff --git a/packages/flutter_tools/test/general.shard/ios/xcresult_test.dart b/packages/flutter_tools/test/general.shard/ios/xcresult_test.dart index 72b78681c838f..19f6944b0895a 100644 --- a/packages/flutter_tools/test/general.shard/ios/xcresult_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/xcresult_test.dart @@ -204,6 +204,19 @@ void main() { expect(result.parsingErrorMessage, isNull); }); + testWithoutContext( + 'correctly parse sample result json with action issues.', () async { + final XCResultGenerator generator = setupGenerator(resultJson: kSampleResultJsonWithActionIssues); + final XCResultIssueDiscarder discarder = XCResultIssueDiscarder(typeMatcher: XCResultIssueType.warning); + final XCResult result = await generator.generate(issueDiscarders: [discarder]); + expect(result.issues.length, 1); + expect(result.issues.first.type, XCResultIssueType.error); + expect(result.issues.first.subType, 'Uncategorized'); + expect(result.issues.first.message, contains('Unable to find a destination matching the provided destination specifier')); + expect(result.parseSuccess, isTrue); + expect(result.parsingErrorMessage, isNull); + }); + testWithoutContext( 'error: `xcresulttool get` process fail should return an `XCResult` with stderr as `parsingErrorMessage`.', () async { diff --git a/packages/flutter_tools/test/general.shard/ios/xcresult_test_data.dart b/packages/flutter_tools/test/general.shard/ios/xcresult_test_data.dart index 645afd1e876ee..5845262c21851 100644 --- a/packages/flutter_tools/test/general.shard/ios/xcresult_test_data.dart +++ b/packages/flutter_tools/test/general.shard/ios/xcresult_test_data.dart @@ -378,3 +378,252 @@ const String kSampleResultJsonWithProvisionIssue = r''' } } '''; + + +/// An example xcresult bundle json that contains action issues. +const String kSampleResultJsonWithActionIssues = r''' +{ + "_type" : { + "_name" : "ActionsInvocationRecord" + }, + "actions" : { + "_type" : { + "_name" : "Array" + }, + "_values" : [ + { + "_type" : { + "_name" : "ActionRecord" + }, + "actionResult" : { + "_type" : { + "_name" : "ActionResult" + }, + "coverage" : { + "_type" : { + "_name" : "CodeCoverageInfo" + } + }, + "issues" : { + "_type" : { + "_name" : "ResultIssueSummaries" + }, + "testFailureSummaries" : { + "_type" : { + "_name" : "Array" + }, + "_values" : [ + { + "_type" : { + "_name" : "TestFailureIssueSummary", + "_supertype" : { + "_name" : "IssueSummary" + } + }, + "issueType" : { + "_type" : { + "_name" : "String" + }, + "_value" : "Uncategorized" + }, + "message" : { + "_type" : { + "_name" : "String" + }, + "_value" : "Unable to find a destination matching the provided destination specifier:\n\t\t{ id:1234D567-890C-1DA2-34E5-F6789A0123C4 }\n\n\tIneligible destinations for the \"Runner\" scheme:\n\t\t{ platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device, error:iOS 17.0 is not installed. To use with Xcode, first download and install the platform }" + } + } + ] + } + }, + "logRef" : { + "_type" : { + "_name" : "Reference" + }, + "id" : { + "_type" : { + "_name" : "String" + }, + "_value" : "0~5X-qvql8_ppq0bj9taBMeZd4L2lXQagy1twsFRWwc06r42obpBZfP87uKnGO98mp5CUz1Ppr1knHiTMH9tOuwQ==" + }, + "targetType" : { + "_type" : { + "_name" : "TypeDefinition" + }, + "name" : { + "_type" : { + "_name" : "String" + }, + "_value" : "ActivityLogSection" + } + } + }, + "metrics" : { + "_type" : { + "_name" : "ResultMetrics" + } + }, + "resultName" : { + "_type" : { + "_name" : "String" + }, + "_value" : "All Tests" + }, + "status" : { + "_type" : { + "_name" : "String" + }, + "_value" : "failedToStart" + }, + "testsRef" : { + "_type" : { + "_name" : "Reference" + }, + "id" : { + "_type" : { + "_name" : "String" + }, + "_value" : "0~Dmuz8-g6YRb8HPVbTUXJD21oy3r5jxIGi-njd2Lc43yR5JlJf7D78HtNn2BsrF5iw1uYMnsuJ9xFDV7ZAmwhGg==" + }, + "targetType" : { + "_type" : { + "_name" : "TypeDefinition" + }, + "name" : { + "_type" : { + "_name" : "String" + }, + "_value" : "ActionTestPlanRunSummaries" + } + } + } + }, + "buildResult" : { + "_type" : { + "_name" : "ActionResult" + }, + "coverage" : { + "_type" : { + "_name" : "CodeCoverageInfo" + } + }, + "issues" : { + "_type" : { + "_name" : "ResultIssueSummaries" + } + }, + "metrics" : { + "_type" : { + "_name" : "ResultMetrics" + } + }, + "resultName" : { + "_type" : { + "_name" : "String" + }, + "_value" : "Build Succeeded" + }, + "status" : { + "_type" : { + "_name" : "String" + }, + "_value" : "succeeded" + } + }, + "endedTime" : { + "_type" : { + "_name" : "Date" + }, + "_value" : "2023-07-10T12:52:22.592-0500" + }, + "runDestination" : { + "_type" : { + "_name" : "ActionRunDestinationRecord" + }, + "localComputerRecord" : { + "_type" : { + "_name" : "ActionDeviceRecord" + }, + "platformRecord" : { + "_type" : { + "_name" : "ActionPlatformRecord" + } + } + }, + "targetDeviceRecord" : { + "_type" : { + "_name" : "ActionDeviceRecord" + }, + "platformRecord" : { + "_type" : { + "_name" : "ActionPlatformRecord" + } + } + }, + "targetSDKRecord" : { + "_type" : { + "_name" : "ActionSDKRecord" + } + } + }, + "schemeCommandName" : { + "_type" : { + "_name" : "String" + }, + "_value" : "Test" + }, + "schemeTaskName" : { + "_type" : { + "_name" : "String" + }, + "_value" : "BuildAndAction" + }, + "startedTime" : { + "_type" : { + "_name" : "Date" + }, + "_value" : "2023-07-10T12:52:22.592-0500" + }, + "title" : { + "_type" : { + "_name" : "String" + }, + "_value" : "RunnerTests.xctest" + } + } + ] + }, + "issues" : { + "_type" : { + "_name" : "ResultIssueSummaries" + } + }, + "metadataRef" : { + "_type" : { + "_name" : "Reference" + }, + "id" : { + "_type" : { + "_name" : "String" + }, + "_value" : "0~pY0GqmiVE6Q3qlWdLJDp_PnrsUKsJ7KKM1zKGnvEZOWGdBeGNArjjU62kgF2UBFdQLdRmf5SGpImQfJB6e7vDQ==" + }, + "targetType" : { + "_type" : { + "_name" : "TypeDefinition" + }, + "name" : { + "_type" : { + "_name" : "String" + }, + "_value" : "ActionsInvocationMetadata" + } + } + }, + "metrics" : { + "_type" : { + "_name" : "ResultMetrics" + } + } +} +'''; From 53841b2cc1436a0f597c6197cf765895a05b747b Mon Sep 17 00:00:00 2001 From: Christopher Fujino Date: Mon, 14 Aug 2023 14:22:12 -0700 Subject: [PATCH 17/58] [CP] Fix #132160 RPCError during web debugging (#132356) Upstream issue: https://github.com/flutter/flutter/issues/132160 Upstream (DWDS) fix PR: https://github.com/dart-lang/webdev/pull/2188 Beta 3.13 beta CP request: https://github.com/flutter/flutter/issues/132358 --- packages/flutter_tools/pubspec.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/flutter_tools/pubspec.yaml b/packages/flutter_tools/pubspec.yaml index 48c32b376abe3..483a9b0213c2a 100644 --- a/packages/flutter_tools/pubspec.yaml +++ b/packages/flutter_tools/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: args: 2.4.2 browser_launcher: 1.1.1 dds: 2.9.0+hotfix - dwds: 19.0.1 + dwds: 19.0.1+1 completion: 1.0.1 coverage: 1.6.3 crypto: 3.0.3 @@ -106,4 +106,4 @@ dartdoc: # Exclude this package from the hosted API docs. nodoc: true -# PUBSPEC CHECKSUM: 3b57 +# PUBSPEC CHECKSUM: 4bb3 From efbf63d9c66b9f6ec30e9ad4611189aa80003d31 Mon Sep 17 00:00:00 2001 From: Kevin Chisholm Date: Tue, 15 Aug 2023 21:05:06 -0500 Subject: [PATCH 18/58] [flutter_releases] Flutter stable 3.13.0 Framework Cherrypicks (#132610) # Flutter stable 3.13.0 Framework --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 58f798c4fbd39..beb9505353a20 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -73d89ca7c5a20cbddde69ab1a6dff8fe3ecbfd70 +1ac611c64eadbd93c5f5aba5494b8fc3b35ee952 From ce2fa57e4556a573557d535f7478005b28f05153 Mon Sep 17 00:00:00 2001 From: Victoria Ashworth <15619084+vashworth@users.noreply.github.com> Date: Tue, 22 Aug 2023 16:04:11 -0500 Subject: [PATCH 19/58] [CP - 3.13] Fix Xcode 15 build failure due to DT_TOOLCHAIN_DIR (#132958) Original PR: https://github.com/flutter/flutter/pull/132803 --- .../lib/src/macos/cocoapods.dart | 6 + ...coapods_toolchain_directory_migration.dart | 62 ++++++++ .../flutter_tools/lib/src/xcode_project.dart | 9 +- .../ios/ios_project_migration_test.dart | 148 ++++++++++++++++++ 4 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 packages/flutter_tools/lib/src/migrations/cocoapods_toolchain_directory_migration.dart diff --git a/packages/flutter_tools/lib/src/macos/cocoapods.dart b/packages/flutter_tools/lib/src/macos/cocoapods.dart index 95a967e450619..e675b50c5efa8 100644 --- a/packages/flutter_tools/lib/src/macos/cocoapods.dart +++ b/packages/flutter_tools/lib/src/macos/cocoapods.dart @@ -19,6 +19,7 @@ import '../build_info.dart'; import '../cache.dart'; import '../ios/xcodeproj.dart'; import '../migrations/cocoapods_script_symlink.dart'; +import '../migrations/cocoapods_toolchain_directory_migration.dart'; import '../reporting/reporting.dart'; import '../xcode_project.dart'; @@ -172,6 +173,11 @@ class CocoaPods { // This migrator works around a CocoaPods bug, and should be run after `pod install` is run. final ProjectMigration postPodMigration = ProjectMigration([ CocoaPodsScriptReadlink(xcodeProject, _xcodeProjectInterpreter, _logger), + CocoaPodsToolchainDirectoryMigration( + xcodeProject, + _xcodeProjectInterpreter, + _logger, + ), ]); postPodMigration.run(); diff --git a/packages/flutter_tools/lib/src/migrations/cocoapods_toolchain_directory_migration.dart b/packages/flutter_tools/lib/src/migrations/cocoapods_toolchain_directory_migration.dart new file mode 100644 index 0000000000000..4ab406bda6819 --- /dev/null +++ b/packages/flutter_tools/lib/src/migrations/cocoapods_toolchain_directory_migration.dart @@ -0,0 +1,62 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import '../base/file_system.dart'; +import '../base/project_migrator.dart'; +import '../base/version.dart'; +import '../ios/xcodeproj.dart'; +import '../xcode_project.dart'; + +/// Starting in Xcode 15, when building macOS, DT_TOOLCHAIN_DIR cannot be used +/// to evaluate LD_RUNPATH_SEARCH_PATHS or LIBRARY_SEARCH_PATHS. `xcodebuild` +/// error message recommend using TOOLCHAIN_DIR instead. +/// +/// This has been fixed upstream in CocoaPods, but migrate a copy of their +/// workaround so users don't need to update. +class CocoaPodsToolchainDirectoryMigration extends ProjectMigrator { + CocoaPodsToolchainDirectoryMigration( + XcodeBasedProject project, + XcodeProjectInterpreter xcodeProjectInterpreter, + super.logger, + ) : _podRunnerTargetSupportFiles = project.podRunnerTargetSupportFiles, + _xcodeProjectInterpreter = xcodeProjectInterpreter; + + final Directory _podRunnerTargetSupportFiles; + final XcodeProjectInterpreter _xcodeProjectInterpreter; + + @override + void migrate() { + if (!_podRunnerTargetSupportFiles.existsSync()) { + logger.printTrace('CocoaPods Pods-Runner Target Support Files not found, skipping TOOLCHAIN_DIR workaround.'); + return; + } + + final Version? version = _xcodeProjectInterpreter.version; + + // If Xcode not installed or less than 15, skip this migration. + if (version == null || version < Version(15, 0, 0)) { + logger.printTrace('Detected Xcode version is $version, below 15.0, skipping TOOLCHAIN_DIR workaround.'); + return; + } + + final List files = _podRunnerTargetSupportFiles.listSync(); + for (final FileSystemEntity file in files) { + if (file.basename.endsWith('xcconfig') && file is File) { + processFileLines(file); + } + } + } + + @override + String? migrateLine(String line) { + final String trimmedString = line.trim(); + if (trimmedString.startsWith('LD_RUNPATH_SEARCH_PATHS') || trimmedString.startsWith('LIBRARY_SEARCH_PATHS')) { + const String originalReadLinkLine = r'{DT_TOOLCHAIN_DIR}'; + const String replacementReadLinkLine = r'{TOOLCHAIN_DIR}'; + + return line.replaceAll(originalReadLinkLine, replacementReadLinkLine); + } + return line; + } +} diff --git a/packages/flutter_tools/lib/src/xcode_project.dart b/packages/flutter_tools/lib/src/xcode_project.dart index 0e658a410d1ab..add59a60b1b28 100644 --- a/packages/flutter_tools/lib/src/xcode_project.dart +++ b/packages/flutter_tools/lib/src/xcode_project.dart @@ -104,11 +104,14 @@ abstract class XcodeBasedProject extends FlutterProjectPlatform { File get podManifestLock => hostAppRoot.childDirectory('Pods').childFile('Manifest.lock'); /// The CocoaPods generated 'Pods-Runner-frameworks.sh'. - File get podRunnerFrameworksScript => hostAppRoot + File get podRunnerFrameworksScript => podRunnerTargetSupportFiles + .childFile('Pods-Runner-frameworks.sh'); + + /// The CocoaPods generated directory 'Pods-Runner'. + Directory get podRunnerTargetSupportFiles => hostAppRoot .childDirectory('Pods') .childDirectory('Target Support Files') - .childDirectory('Pods-Runner') - .childFile('Pods-Runner-frameworks.sh'); + .childDirectory('Pods-Runner'); } /// Represents the iOS sub-project of a Flutter project. diff --git a/packages/flutter_tools/test/general.shard/ios/ios_project_migration_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_project_migration_test.dart index c5ed8b619cca7..3f2277a22c83b 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_project_migration_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_project_migration_test.dart @@ -16,6 +16,7 @@ import 'package:flutter_tools/src/ios/migrations/remove_framework_link_and_embed import 'package:flutter_tools/src/ios/migrations/xcode_build_system_migration.dart'; import 'package:flutter_tools/src/ios/xcodeproj.dart'; import 'package:flutter_tools/src/migrations/cocoapods_script_symlink.dart'; +import 'package:flutter_tools/src/migrations/cocoapods_toolchain_directory_migration.dart'; import 'package:flutter_tools/src/migrations/xcode_project_object_version_migration.dart'; import 'package:flutter_tools/src/migrations/xcode_script_build_phase_migration.dart'; import 'package:flutter_tools/src/migrations/xcode_thin_binary_build_phase_input_paths_migration.dart'; @@ -1003,6 +1004,150 @@ platform :ios, '11.0' expect(testLogger.statusText, contains('Upgrading Pods-Runner-frameworks.sh')); }); }); + + group('Cocoapods migrate toolchain directory', () { + late MemoryFileSystem memoryFileSystem; + late BufferLogger testLogger; + late FakeIosProject project; + late Directory podRunnerTargetSupportFiles; + late ProcessManager processManager; + late XcodeProjectInterpreter xcode15ProjectInterpreter; + + setUp(() { + memoryFileSystem = MemoryFileSystem(); + podRunnerTargetSupportFiles = memoryFileSystem.directory('Pods-Runner'); + testLogger = BufferLogger.test(); + project = FakeIosProject(); + processManager = FakeProcessManager.any(); + xcode15ProjectInterpreter = XcodeProjectInterpreter.test(processManager: processManager, version: Version(15, 0, 0)); + project.podRunnerTargetSupportFiles = podRunnerTargetSupportFiles; + }); + + testWithoutContext('skip if directory is missing', () { + final CocoaPodsToolchainDirectoryMigration iosProjectMigration = CocoaPodsToolchainDirectoryMigration( + project, + xcode15ProjectInterpreter, + testLogger, + ); + iosProjectMigration.migrate(); + expect(podRunnerTargetSupportFiles.existsSync(), isFalse); + + expect(testLogger.traceText, contains('CocoaPods Pods-Runner Target Support Files not found')); + expect(testLogger.statusText, isEmpty); + }); + + testWithoutContext('skip if xcconfig files are missing', () { + podRunnerTargetSupportFiles.createSync(); + final CocoaPodsToolchainDirectoryMigration iosProjectMigration = CocoaPodsToolchainDirectoryMigration( + project, + xcode15ProjectInterpreter, + testLogger, + ); + iosProjectMigration.migrate(); + expect(podRunnerTargetSupportFiles.existsSync(), isTrue); + expect(testLogger.traceText, isEmpty); + expect(testLogger.statusText, isEmpty); + }); + + testWithoutContext('skip if nothing to upgrade', () { + podRunnerTargetSupportFiles.createSync(); + final File debugConfig = podRunnerTargetSupportFiles.childFile('Pods-Runner.debug.xcconfig'); + const String contents = r''' +LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/../Frameworks' '@loader_path/Frameworks' "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" +LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift +'''; + debugConfig.writeAsStringSync(contents); + + final File profileConfig = podRunnerTargetSupportFiles.childFile('Pods-Runner.profile.xcconfig'); + profileConfig.writeAsStringSync(contents); + + final File releaseConfig = podRunnerTargetSupportFiles.childFile('Pods-Runner.release.xcconfig'); + releaseConfig.writeAsStringSync(contents); + + final CocoaPodsToolchainDirectoryMigration iosProjectMigration = CocoaPodsToolchainDirectoryMigration( + project, + xcode15ProjectInterpreter, + testLogger, + ); + iosProjectMigration.migrate(); + expect(debugConfig.existsSync(), isTrue); + expect(testLogger.traceText, isEmpty); + expect(testLogger.statusText, isEmpty); + }); + + testWithoutContext('skipped if Xcode version below 15', () { + podRunnerTargetSupportFiles.createSync(); + final File debugConfig = podRunnerTargetSupportFiles.childFile('Pods-Runner.debug.xcconfig'); + const String contents = r''' +LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/../Frameworks' '@loader_path/Frameworks' "${DT_TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" +LIBRARY_SEARCH_PATHS = $(inherited) "${DT_TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift +'''; + debugConfig.writeAsStringSync(contents); + + final File profileConfig = podRunnerTargetSupportFiles.childFile('Pods-Runner.profile.xcconfig'); + profileConfig.writeAsStringSync(contents); + + final File releaseConfig = podRunnerTargetSupportFiles.childFile('Pods-Runner.release.xcconfig'); + releaseConfig.writeAsStringSync(contents); + + final XcodeProjectInterpreter xcode14ProjectInterpreter = XcodeProjectInterpreter.test( + processManager: processManager, + version: Version(14, 0, 0), + ); + + final CocoaPodsToolchainDirectoryMigration iosProjectMigration = CocoaPodsToolchainDirectoryMigration( + project, + xcode14ProjectInterpreter, + testLogger, + ); + iosProjectMigration.migrate(); + expect(debugConfig.existsSync(), isTrue); + expect(testLogger.traceText, contains('Detected Xcode version is 14.0.0, below 15.0')); + expect(testLogger.statusText, isEmpty); + }); + + testWithoutContext('Xcode project is migrated and ignores leading whitespace', () { + podRunnerTargetSupportFiles.createSync(); + final File debugConfig = podRunnerTargetSupportFiles.childFile('Pods-Runner.debug.xcconfig'); + const String contents = r''' +LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/../Frameworks' '@loader_path/Frameworks' "${DT_TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" + LIBRARY_SEARCH_PATHS = $(inherited) "${DT_TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift +'''; + debugConfig.writeAsStringSync(contents); + + final File profileConfig = podRunnerTargetSupportFiles.childFile('Pods-Runner.profile.xcconfig'); + profileConfig.writeAsStringSync(contents); + + final File releaseConfig = podRunnerTargetSupportFiles.childFile('Pods-Runner.release.xcconfig'); + releaseConfig.writeAsStringSync(contents); + + final CocoaPodsToolchainDirectoryMigration iosProjectMigration = CocoaPodsToolchainDirectoryMigration( + project, + xcode15ProjectInterpreter, + testLogger, + ); + iosProjectMigration.migrate(); + + expect(debugConfig.existsSync(), isTrue); + expect(debugConfig.readAsStringSync(), r''' +LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/../Frameworks' '@loader_path/Frameworks' "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" + LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift +'''); + expect(profileConfig.existsSync(), isTrue); + expect(profileConfig.readAsStringSync(), r''' +LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/../Frameworks' '@loader_path/Frameworks' "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" + LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift +'''); + expect(releaseConfig.existsSync(), isTrue); + expect(releaseConfig.readAsStringSync(), r''' +LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/../Frameworks' '@loader_path/Frameworks' "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" + LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift +'''); + expect(testLogger.statusText, contains('Upgrading Pods-Runner.debug.xcconfig')); + expect(testLogger.statusText, contains('Upgrading Pods-Runner.profile.xcconfig')); + expect(testLogger.statusText, contains('Upgrading Pods-Runner.release.xcconfig')); + }); + }); }); group('update Xcode script build phase', () { @@ -1239,6 +1384,9 @@ class FakeIosProject extends Fake implements IosProject { @override File podRunnerFrameworksScript = MemoryFileSystem.test().file('podRunnerFrameworksScript'); + + @override + Directory podRunnerTargetSupportFiles = MemoryFileSystem.test().directory('Pods-Runner'); } class FakeIOSMigrator extends ProjectMigrator { From aeed4d21ee093f1835afaff309945d7180de8b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20S=20Guerrero?= Date: Tue, 22 Aug 2023 16:32:40 -0700 Subject: [PATCH 20/58] [flutter_releases] Flutter stable 3.13.1 Framework Cherrypicks (#133077) # Flutter stable 3.13.1 Framework ## Scheduled Cherrypicks --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index beb9505353a20..4806d42d7936a 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -1ac611c64eadbd93c5f5aba5494b8fc3b35ee952 +b20183e04096094bcc37d9cde2a4b96f5cc684cf From e1e47221e86272429674bec4f1bd36acc4fc7b77 Mon Sep 17 00:00:00 2001 From: Jackson Gardner Date: Tue, 22 Aug 2023 21:43:18 -0700 Subject: [PATCH 21/58] [CP] Space character should be optional when tree shaking fonts (#132882) This is the cherry-pick for https://github.com/flutter/flutter/pull/132880 --- .../targets/icon_tree_shaker.dart | 26 +++++++++++++------ .../targets/icon_tree_shaker_test.dart | 4 +-- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/flutter_tools/lib/src/build_system/targets/icon_tree_shaker.dart b/packages/flutter_tools/lib/src/build_system/targets/icon_tree_shaker.dart index 91557e7b06bfc..7f3b0dee8bd56 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/icon_tree_shaker.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/icon_tree_shaker.dart @@ -138,15 +138,15 @@ class IconTreeShaker { if (codePoints == null) { throw IconTreeShakerException._('Expected to font code points for ${entry.key}, but none were found.'); } - if (_targetPlatform == TargetPlatform.web_javascript) { - if (!codePoints.contains(kSpacePoint)) { - codePoints.add(kSpacePoint); - } - } + + // Add space as an optional code point, as web uses it to measure the font height. + final List optionalCodePoints = _targetPlatform == TargetPlatform.web_javascript + ? [kSpacePoint] : []; result[entry.value] = _IconTreeShakerData( family: entry.key, relativePath: entry.value, codePoints: codePoints, + optionalCodePoints: optionalCodePoints, ); } _iconData = result; @@ -197,12 +197,17 @@ class IconTreeShaker { outputPath, input.path, ]; - final String codePoints = iconTreeShakerData.codePoints.join(' '); + final Iterable requiredCodePointStrings = iconTreeShakerData.codePoints + .map((int codePoint) => codePoint.toString()); + final Iterable optionalCodePointStrings = iconTreeShakerData.optionalCodePoints + .map((int codePoint) => 'optional:$codePoint'); + final String codePointsString = requiredCodePointStrings + .followedBy(optionalCodePointStrings).join(' '); _logger.printTrace('Running font-subset: ${cmd.join(' ')}, ' - 'using codepoints $codePoints'); + 'using codepoints $codePointsString'); final Process fontSubsetProcess = await _processManager.start(cmd); try { - fontSubsetProcess.stdin.writeln(codePoints); + fontSubsetProcess.stdin.writeln(codePointsString); await fontSubsetProcess.stdin.flush(); await fontSubsetProcess.stdin.close(); } on Exception { @@ -369,6 +374,7 @@ class _IconTreeShakerData { required this.family, required this.relativePath, required this.codePoints, + required this.optionalCodePoints, }); /// The font family name, e.g. "MaterialIcons". @@ -380,6 +386,10 @@ class _IconTreeShakerData { /// The list of code points for the font. final List codePoints; + /// The list of code points to be optionally added, if they exist in the + /// input font. Otherwise, the tool will silently omit them. + final List optionalCodePoints; + @override String toString() => 'FontSubsetData($family, $relativePath, $codePoints)'; } diff --git a/packages/flutter_tools/test/general.shard/build_system/targets/icon_tree_shaker_test.dart b/packages/flutter_tools/test/general.shard/build_system/targets/icon_tree_shaker_test.dart index d786570798a1a..cb493e7cec765 100644 --- a/packages/flutter_tools/test/general.shard/build_system/targets/icon_tree_shaker_test.dart +++ b/packages/flutter_tools/test/general.shard/build_system/targets/icon_tree_shaker_test.dart @@ -411,7 +411,7 @@ void main() { expect(result, isTrue); final List codePoints = stdinSink.getAndClear().trim().split(whitespace); - expect(codePoints, isNot(contains('32'))); + expect(codePoints, isNot(contains('optional:32'))); expect(processManager, hasNoRemainingExpectations); }); @@ -456,7 +456,7 @@ void main() { expect(result, isTrue); final List codePoints = stdinSink.getAndClear().trim().split(whitespace); - expect(codePoints, containsAllInOrder(const ['59470', '32'])); + expect(codePoints, containsAllInOrder(const ['59470', 'optional:32'])); expect(processManager, hasNoRemainingExpectations); }); From ff5b5b5fa6f35b717667719ddfdb1521d8bdd05a Mon Sep 17 00:00:00 2001 From: Kate Lovett Date: Thu, 24 Aug 2023 08:12:28 -0500 Subject: [PATCH 22/58] [CP] Fix lower bound of children from TwoDimensionalChildBuilderDelegate (#132764) Found in https://github.com/flutter/packages/pull/4536 The max x and max y index should allow for a case where there are no children in the viewport. This should be CP'd into stable once it lands. --- .../lib/src/widgets/scroll_delegate.dart | 12 +++++++----- .../widgets/two_dimensional_viewport_test.dart | 18 ++++++++++-------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/flutter/lib/src/widgets/scroll_delegate.dart b/packages/flutter/lib/src/widgets/scroll_delegate.dart index 765bf31d8a1a7..ba5e826b9498c 100644 --- a/packages/flutter/lib/src/widgets/scroll_delegate.dart +++ b/packages/flutter/lib/src/widgets/scroll_delegate.dart @@ -933,8 +933,8 @@ class TwoDimensionalChildBuilderDelegate extends TwoDimensionalChildDelegate { required this.builder, int? maxXIndex, int? maxYIndex, - }) : assert(maxYIndex == null || maxYIndex >= 0), - assert(maxXIndex == null || maxXIndex >= 0), + }) : assert(maxYIndex == null || maxYIndex >= -1), + assert(maxXIndex == null || maxXIndex >= -1), _maxYIndex = maxYIndex, _maxXIndex = maxXIndex; @@ -976,7 +976,9 @@ class TwoDimensionalChildBuilderDelegate extends TwoDimensionalChildDelegate { /// [TwoDimensionalViewport] subclass to learn how this value is applied in /// the specific use case. /// - /// If not null, the value must be non-negative. + /// If not null, the value must be greater than or equal to -1, where -1 + /// indicates there will be no children at all provided to the + /// [TwoDimensionalViewport]. /// /// If the value changes, the delegate will call [notifyListeners]. This /// informs the [RenderTwoDimensionalViewport] that any cached information @@ -997,7 +999,7 @@ class TwoDimensionalChildBuilderDelegate extends TwoDimensionalChildDelegate { if (value == maxXIndex) { return; } - assert(value == null || value >= 0); + assert(value == null || value >= -1); _maxXIndex = value; notifyListeners(); } @@ -1020,7 +1022,7 @@ class TwoDimensionalChildBuilderDelegate extends TwoDimensionalChildDelegate { if (maxYIndex == value) { return; } - assert(value == null || value >= 0); + assert(value == null || value >= -1); _maxYIndex = value; notifyListeners(); } diff --git a/packages/flutter/test/widgets/two_dimensional_viewport_test.dart b/packages/flutter/test/widgets/two_dimensional_viewport_test.dart index a445d3412c97b..53fb879e0759b 100644 --- a/packages/flutter/test/widgets/two_dimensional_viewport_test.dart +++ b/packages/flutter/test/widgets/two_dimensional_viewport_test.dart @@ -120,27 +120,29 @@ void main() { } ); // Update + delegate.maxXIndex = -1; // No exception. expect( () { - delegate.maxXIndex = -1; + delegate.maxXIndex = -2; }, throwsA( isA().having( (AssertionError error) => error.toString(), 'description', - contains('value == null || value >= 0'), + contains('value == null || value >= -1'), ), ), ); + delegate.maxYIndex = -1; // No exception expect( () { - delegate.maxYIndex = -1; + delegate.maxYIndex = -2; }, throwsA( isA().having( (AssertionError error) => error.toString(), 'description', - contains('value == null || value >= 0'), + contains('value == null || value >= -1'), ), ), ); @@ -148,7 +150,7 @@ void main() { expect( () { TwoDimensionalChildBuilderDelegate( - maxXIndex: -1, + maxXIndex: -2, maxYIndex: 0, builder: (BuildContext context, ChildVicinity vicinity) { return const SizedBox.shrink(); @@ -159,7 +161,7 @@ void main() { isA().having( (AssertionError error) => error.toString(), 'description', - contains('maxXIndex == null || maxXIndex >= 0'), + contains('maxXIndex == null || maxXIndex >= -1'), ), ), ); @@ -167,7 +169,7 @@ void main() { () { TwoDimensionalChildBuilderDelegate( maxXIndex: 0, - maxYIndex: -1, + maxYIndex: -2, builder: (BuildContext context, ChildVicinity vicinity) { return const SizedBox.shrink(); } @@ -177,7 +179,7 @@ void main() { isA().having( (AssertionError error) => error.toString(), 'description', - contains('maxYIndex == null || maxYIndex >= 0'), + contains('maxYIndex == null || maxYIndex >= -1'), ), ), ); From bb77eff18b27ae056bc90b027e86ede7cc02790e Mon Sep 17 00:00:00 2001 From: Christopher Fujino Date: Thu, 31 Aug 2023 17:07:04 -0700 Subject: [PATCH 23/58] [CP] Fix flutter upgrade failing with "Unknown Flutter tag" to 3.13 (#133818) Fixes `flutter upgrade` which would fail with "Unknown flutter tag" because it would call `git` commands in the user's current working directory instead of the Flutter SDK. Fixes https://github.com/flutter/flutter/issues/133819. --- .../lib/src/commands/upgrade.dart | 6 ++- .../commands.shard/hermetic/upgrade_test.dart | 12 ++++++ .../downgrade_upgrade_integration_test.dart | 38 ++++++++++++++----- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/packages/flutter_tools/lib/src/commands/upgrade.dart b/packages/flutter_tools/lib/src/commands/upgrade.dart index b7b54a190aa6e..d9918e385efdc 100644 --- a/packages/flutter_tools/lib/src/commands/upgrade.dart +++ b/packages/flutter_tools/lib/src/commands/upgrade.dart @@ -77,7 +77,11 @@ class UpgradeCommand extends FlutterCommand { force: boolArg('force'), continueFlow: boolArg('continue'), testFlow: stringArg('working-directory') != null, - gitTagVersion: GitTagVersion.determine(globals.processUtils, globals.platform), + gitTagVersion: GitTagVersion.determine( + globals.processUtils, + globals.platform, + workingDirectory: _commandRunner.workingDirectory, + ), flutterVersion: stringArg('working-directory') == null ? globals.flutterVersion : FlutterVersion(flutterRoot: _commandRunner.workingDirectory!, fs: globals.fs), diff --git a/packages/flutter_tools/test/commands.shard/hermetic/upgrade_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/upgrade_test.dart index 2a1db24d57a75..3b7c116fc6eae 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/upgrade_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/upgrade_test.dart @@ -23,9 +23,11 @@ void main() { late FakeProcessManager processManager; UpgradeCommand command; late CommandRunner runner; + const String flutterRoot = '/path/to/flutter'; setUpAll(() { Cache.disableLocking(); + Cache.flutterRoot = flutterRoot; }); setUp(() { @@ -214,28 +216,35 @@ void main() { const FakeCommand( command: ['git', 'tag', '--points-at', 'HEAD'], stdout: startingTag, + workingDirectory: flutterRoot, ), const FakeCommand( command: ['git', 'fetch', '--tags'], + workingDirectory: flutterRoot, ), const FakeCommand( command: ['git', 'rev-parse', '--verify', '@{upstream}'], stdout: upstreamHeadRevision, + workingDirectory: flutterRoot, ), const FakeCommand( command: ['git', 'tag', '--points-at', upstreamHeadRevision], stdout: latestUpstreamTag, + workingDirectory: flutterRoot, ), const FakeCommand( command: ['git', 'status', '-s'], + workingDirectory: flutterRoot, ), const FakeCommand( command: ['git', 'reset', '--hard', upstreamHeadRevision], + workingDirectory: flutterRoot, ), FakeCommand( command: const ['bin/flutter', 'upgrade', '--continue', '--no-version-check'], onRun: reEnterTool, completer: reEntryCompleter, + workingDirectory: flutterRoot, ), // commands following this are from the re-entrant `flutter upgrade --continue` call @@ -243,12 +252,15 @@ void main() { const FakeCommand( command: ['git', 'tag', '--points-at', 'HEAD'], stdout: latestUpstreamTag, + workingDirectory: flutterRoot, ), const FakeCommand( command: ['bin/flutter', '--no-color', '--no-version-check', 'precache'], + workingDirectory: flutterRoot, ), const FakeCommand( command: ['bin/flutter', '--no-version-check', 'doctor'], + workingDirectory: flutterRoot, ), ]); await runner.run(['upgrade']); diff --git a/packages/flutter_tools/test/integration.shard/downgrade_upgrade_integration_test.dart b/packages/flutter_tools/test/integration.shard/downgrade_upgrade_integration_test.dart index 8ca82c0d25cdd..20b7675b78a51 100644 --- a/packages/flutter_tools/test/integration.shard/downgrade_upgrade_integration_test.dart +++ b/packages/flutter_tools/test/integration.shard/downgrade_upgrade_integration_test.dart @@ -15,14 +15,14 @@ const String _kInitialVersion = '3.0.0'; const String _kBranch = 'beta'; final Stdio stdio = Stdio(); -final ProcessUtils processUtils = ProcessUtils(processManager: processManager, logger: StdoutLogger( +final BufferLogger logger = BufferLogger.test( terminal: AnsiTerminal( platform: platform, stdio: stdio, ), - stdio: stdio, outputPreferences: OutputPreferences.test(wrapText: true), -)); +); +final ProcessUtils processUtils = ProcessUtils(processManager: processManager, logger: logger); final String flutterBin = fileSystem.path.join(getFlutterRoot(), 'bin', platform.isWindows ? 'flutter.bat' : 'flutter'); /// A test for flutter upgrade & downgrade that checks out a parallel flutter repo. @@ -48,13 +48,29 @@ void main() { 'git', 'config', '--system', 'core.longpaths', 'true', ]); + void checkExitCode(int code) { + expect( + exitCode, + 0, + reason: ''' +trace: +${logger.traceText} + +status: +${logger.statusText} + +error: +${logger.errorText}''', + ); + } + printOnFailure('Step 1 - clone the $_kBranch of flutter into the test directory'); exitCode = await processUtils.stream([ 'git', 'clone', 'https://github.com/flutter/flutter.git', ], workingDirectory: parentDirectory.path, trace: true); - expect(exitCode, 0); + checkExitCode(exitCode); printOnFailure('Step 2 - switch to the $_kBranch'); exitCode = await processUtils.stream([ @@ -65,7 +81,7 @@ void main() { _kBranch, 'origin/$_kBranch', ], workingDirectory: testDirectory.path, trace: true); - expect(exitCode, 0); + checkExitCode(exitCode); printOnFailure('Step 3 - revert back to $_kInitialVersion'); exitCode = await processUtils.stream([ @@ -74,7 +90,7 @@ void main() { '--hard', _kInitialVersion, ], workingDirectory: testDirectory.path, trace: true); - expect(exitCode, 0); + checkExitCode(exitCode); printOnFailure('Step 4 - upgrade to the newest $_kBranch'); // This should update the persistent tool state with the sha for HEAD @@ -84,8 +100,10 @@ void main() { 'upgrade', '--verbose', '--working-directory=${testDirectory.path}', - ], workingDirectory: testDirectory.path, trace: true); - expect(exitCode, 0); + // we intentionally run this in a directory outside the test repo to + // verify the tool overrides the working directory when invoking git + ], workingDirectory: parentDirectory.path, trace: true); + checkExitCode(exitCode); printOnFailure('Step 5 - verify that the version is different'); final RunResult versionResult = await processUtils.run([ @@ -105,8 +123,8 @@ void main() { 'downgrade', '--no-prompt', '--working-directory=${testDirectory.path}', - ], workingDirectory: testDirectory.path, trace: true); - expect(exitCode, 0); + ], workingDirectory: parentDirectory.path, trace: true); + checkExitCode(exitCode); printOnFailure('Step 7 - verify downgraded version matches original version'); final RunResult oldVersionResult = await processUtils.run([ From 441432b5be564e8519327be82ea494814c5482f3 Mon Sep 17 00:00:00 2001 From: Kate Lovett Date: Tue, 5 Sep 2023 11:51:37 -0500 Subject: [PATCH 24/58] [CP] Fix visual overflow for SliverMainAxisGroup (#132989) (#133057) Cherry picks https://github.com/flutter/flutter/pull/132989 which fixes https://github.com/flutter/flutter/issues/132788 Fixes https://github.com/flutter/flutter/issues/133058 ## 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]. - [x] 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] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/wiki/Tree-hygiene#overview [Tree Hygiene]: https://github.com/flutter/flutter/wiki/Tree-hygiene [test-exempt]: https://github.com/flutter/flutter/wiki/Tree-hygiene#tests [Flutter Style Guide]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo [Features we expect every widget to implement]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#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/wiki/Tree-hygiene#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/wiki/Chat --- packages/flutter/lib/src/rendering/sliver_group.dart | 1 + .../flutter/test/widgets/sliver_main_axis_group_test.dart | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/packages/flutter/lib/src/rendering/sliver_group.dart b/packages/flutter/lib/src/rendering/sliver_group.dart index 6057be87add96..b47daff6da70e 100644 --- a/packages/flutter/lib/src/rendering/sliver_group.dart +++ b/packages/flutter/lib/src/rendering/sliver_group.dart @@ -286,6 +286,7 @@ class RenderSliverMainAxisGroup extends RenderSliver with ContainerRenderObjectM scrollExtent: totalScrollExtent, paintExtent: calculatePaintOffset(constraints, from: 0, to: totalScrollExtent), maxPaintExtent: maxPaintExtent, + hasVisualOverflow: totalScrollExtent > constraints.remainingPaintExtent || constraints.scrollOffset > 0.0, ); } diff --git a/packages/flutter/test/widgets/sliver_main_axis_group_test.dart b/packages/flutter/test/widgets/sliver_main_axis_group_test.dart index 7851cb430c44f..46d518589d381 100644 --- a/packages/flutter/test/widgets/sliver_main_axis_group_test.dart +++ b/packages/flutter/test/widgets/sliver_main_axis_group_test.dart @@ -58,6 +58,7 @@ void main() { final RenderSliverMainAxisGroup renderGroup = tester.renderObject(find.byType(SliverMainAxisGroup)); expect(renderGroup.geometry!.scrollExtent, equals(300 * 20 + 200 * 20)); + expect(renderGroup.geometry!.hasVisualOverflow, isTrue); }); testWidgets('SliverMainAxisGroup is laid out properly when reversed', (WidgetTester tester) async { @@ -109,6 +110,7 @@ void main() { final RenderSliverMainAxisGroup renderGroup = tester.renderObject(find.byType(SliverMainAxisGroup)); expect(renderGroup.geometry!.scrollExtent, equals(300 * 20 + 200 * 20)); + expect(renderGroup.geometry!.hasVisualOverflow, isTrue); }); testWidgets('SliverMainAxisGroup is laid out properly when horizontal', (WidgetTester tester) async { @@ -165,6 +167,7 @@ void main() { final RenderSliverMainAxisGroup renderGroup = tester.renderObject(find.byType(SliverMainAxisGroup)); expect(renderGroup.geometry!.scrollExtent, equals(300 * 20 + 200 * 20)); + expect(renderGroup.geometry!.hasVisualOverflow, isTrue); }); testWidgets('SliverMainAxisGroup is laid out properly when horizontal, reversed', (WidgetTester tester) async { @@ -222,6 +225,7 @@ void main() { final RenderSliverMainAxisGroup renderGroup = tester.renderObject(find.byType(SliverMainAxisGroup)); expect(renderGroup.geometry!.scrollExtent, equals(300 * 20 + 200 * 20)); + expect(renderGroup.geometry!.hasVisualOverflow, isTrue); }); testWidgets('Hit test works properly on various parts of SliverMainAxisGroup', (WidgetTester tester) async { From ad5235b4e482e4063ea6fa4be24f2ab8be73f750 Mon Sep 17 00:00:00 2001 From: David Iglesias Date: Tue, 5 Sep 2023 18:44:49 -0700 Subject: [PATCH 25/58] [CP] Migrate web-only initialization APIs (#133891) This CP lands PR https://github.com/flutter/flutter/pull/129856 into `stable`. The PR above was part of a engine+framework change that got split in half during the stable cut, so now users are seeing some warnings that they can't act on. (Those warnings were only intended for people who were using our methods manually, rather than using normal flutter tooling). ## Issues Fixes https://github.com/flutter/flutter/issues/133069 --- .../flutter/lib/src/foundation/_platform_web.dart | 6 ++---- packages/flutter_tools/lib/src/web/bootstrap.dart | 3 ++- .../lib/src/web/file_generators/main_dart.dart | 4 ++-- .../general.shard/build_system/targets/web_test.dart | 8 ++++---- .../test/general.shard/resident_web_runner_test.dart | 4 ++-- .../flutter_web_plugins/lib/src/plugin_registry.dart | 11 +++++------ .../test/plugin_event_channel_test.dart | 4 ++-- .../test/plugin_registry_test.dart | 4 ++-- 8 files changed, 21 insertions(+), 23 deletions(-) diff --git a/packages/flutter/lib/src/foundation/_platform_web.dart b/packages/flutter/lib/src/foundation/_platform_web.dart index 788cbf4c7f26d..babeee421a23f 100644 --- a/packages/flutter/lib/src/foundation/_platform_web.dart +++ b/packages/flutter/lib/src/foundation/_platform_web.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:ui' as ui; +import 'dart:ui_web' as ui_web; import '../services/dom.dart'; @@ -23,9 +23,7 @@ platform.TargetPlatform get defaultTargetPlatform { final platform.TargetPlatform? _testPlatform = () { platform.TargetPlatform? result; assert(() { - // This member is only available in the web's dart:ui implementation. - // ignore: undefined_prefixed_name - if (ui.debugEmulateFlutterTesterEnvironment as bool) { + if (ui_web.debugEmulateFlutterTesterEnvironment) { result = platform.TargetPlatform.android; } return true; diff --git a/packages/flutter_tools/lib/src/web/bootstrap.dart b/packages/flutter_tools/lib/src/web/bootstrap.dart index 5f5f6d1f697cd..de08019febe0c 100644 --- a/packages/flutter_tools/lib/src/web/bootstrap.dart +++ b/packages/flutter_tools/lib/src/web/bootstrap.dart @@ -219,6 +219,7 @@ String generateTestEntrypoint({ // @dart = ${languageVersion.major}.${languageVersion.minor} import 'org-dartlang-app:///$relativeTestPath' as test; import 'dart:ui' as ui; + import 'dart:ui_web' as ui_web; import 'dart:html'; import 'dart:js'; ${testConfigPath != null ? "import '${Uri.file(testConfigPath)}' as test_config;" : ""} @@ -227,7 +228,7 @@ String generateTestEntrypoint({ import 'package:test_api/backend.dart'; Future main() async { - ui.debugEmulateFlutterTesterEnvironment = true; + ui_web.debugEmulateFlutterTesterEnvironment = true; await ui.webOnlyInitializePlatform(); webGoldenComparator = DefaultWebGoldenComparator(Uri.parse('${Uri.file(absolutePath)}')); (ui.window as dynamic).debugOverrideDevicePixelRatio(3.0); diff --git a/packages/flutter_tools/lib/src/web/file_generators/main_dart.dart b/packages/flutter_tools/lib/src/web/file_generators/main_dart.dart index 9d87fe1a3e72a..040e4097ab796 100644 --- a/packages/flutter_tools/lib/src/web/file_generators/main_dart.dart +++ b/packages/flutter_tools/lib/src/web/file_generators/main_dart.dart @@ -19,7 +19,7 @@ String generateMainDartFile(String appEntrypoint, { '', '// ignore_for_file: type=lint', '', - "import 'dart:ui' as ui;", + "import 'dart:ui_web' as ui_web;", "import 'dart:async';", '', "import '$appEntrypoint' as entrypoint;", @@ -29,7 +29,7 @@ String generateMainDartFile(String appEntrypoint, { 'typedef _NullaryFunction = dynamic Function();', '', 'Future main() async {', - ' await ui.webOnlyWarmupEngine(', + ' await ui_web.bootstrapEngine(', ' runApp: () {', ' if (entrypoint.main is _UnaryFunction) {', ' return (entrypoint.main as _UnaryFunction)([]);', diff --git a/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart b/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart index d2b79401eaa96..8e47324eda371 100644 --- a/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart +++ b/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart @@ -118,7 +118,7 @@ void main() { expect(generated, contains("import 'package:foo/main.dart' as entrypoint;")); // Main - expect(generated, contains('ui.webOnlyWarmupEngine(')); + expect(generated, contains('ui_web.bootstrapEngine(')); expect(generated, contains('entrypoint.main as _')); }, overrides: { TemplateRenderer: () => const MustacheTemplateRenderer(), @@ -270,7 +270,7 @@ void main() { expect(generated, contains("import 'package:foo/main.dart' as entrypoint;")); // Main - expect(generated, contains('ui.webOnlyWarmupEngine(')); + expect(generated, contains('ui_web.bootstrapEngine(')); expect(generated, contains('entrypoint.main as _')); }, overrides: { Platform: () => windows, @@ -295,7 +295,7 @@ void main() { expect(generated, contains("import 'package:foo/main.dart' as entrypoint;")); // Main - expect(generated, contains('ui.webOnlyWarmupEngine(')); + expect(generated, contains('ui_web.bootstrapEngine(')); expect(generated, contains('entrypoint.main as _')); }, overrides: { TemplateRenderer: () => const MustacheTemplateRenderer(), @@ -351,7 +351,7 @@ void main() { expect(generated, contains("import 'package:foo/main.dart' as entrypoint;")); // Main - expect(generated, contains('ui.webOnlyWarmupEngine(')); + expect(generated, contains('ui_web.bootstrapEngine(')); expect(generated, contains('entrypoint.main as _')); }, overrides: { TemplateRenderer: () => const MustacheTemplateRenderer(), diff --git a/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart b/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart index 9a307d567fce6..ff803f7bbc7f3 100644 --- a/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart +++ b/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart @@ -730,8 +730,8 @@ void main() { final String entrypointContents = fileSystem.file(webDevFS.mainUri).readAsStringSync(); expect(entrypointContents, contains('// Flutter web bootstrap script')); - expect(entrypointContents, contains("import 'dart:ui' as ui;")); - expect(entrypointContents, contains('await ui.webOnlyWarmupEngine(')); + expect(entrypointContents, contains("import 'dart:ui_web' as ui_web;")); + expect(entrypointContents, contains('await ui_web.bootstrapEngine(')); expect(logger.statusText, contains('Restarted application in')); expect(result.code, 0); diff --git a/packages/flutter_web_plugins/lib/src/plugin_registry.dart b/packages/flutter_web_plugins/lib/src/plugin_registry.dart index b4a11f61f3884..78108b9699c39 100644 --- a/packages/flutter_web_plugins/lib/src/plugin_registry.dart +++ b/packages/flutter_web_plugins/lib/src/plugin_registry.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'dart:ui' as ui; +import 'dart:ui_web' as ui_web; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -57,13 +58,11 @@ class Registrar extends BinaryMessenger { /// previously-registered handler and replaces it with the handler /// from this object. /// - /// This method uses a function called `webOnlySetPluginHandler` in - /// the [dart:ui] library. That function is only available when + /// This method uses a function called `setPluginHandler` in + /// the [dart:ui_web] library. That function is only available when /// compiling for the web. void registerMessageHandler() { - // The `ui.webOnlySetPluginHandler` function below is only defined in the Web dart:ui. - // ignore: undefined_function, avoid_dynamic_calls - ui.webOnlySetPluginHandler(handleFrameworkMessage); + ui_web.setPluginHandler(handleFrameworkMessage); } /// Receives a platform message from the framework. @@ -101,7 +100,7 @@ class Registrar extends BinaryMessenger { /// the following: /// /// ```dart - /// ui.webOnlySetPluginHandler(webPluginRegistrar.handleFrameworkMessage); + /// ui_web.setPluginHandler(handleFrameworkMessage); /// ``` Future handleFrameworkMessage( String channel, diff --git a/packages/flutter_web_plugins/test/plugin_event_channel_test.dart b/packages/flutter_web_plugins/test/plugin_event_channel_test.dart index 280580f5a51ae..b43a841bf116c 100644 --- a/packages/flutter_web_plugins/test/plugin_event_channel_test.dart +++ b/packages/flutter_web_plugins/test/plugin_event_channel_test.dart @@ -6,7 +6,7 @@ library; import 'dart:async'; -import 'dart:ui' as ui; +import 'dart:ui_web' as ui_web; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -14,7 +14,7 @@ import 'package:flutter_web_plugins/flutter_web_plugins.dart'; void main() { // Disabling tester emulation because this test relies on real message channel communication. - ui.debugEmulateFlutterTesterEnvironment = false; // ignore: undefined_prefixed_name + ui_web.debugEmulateFlutterTesterEnvironment = false; group('Plugin Event Channel', () { setUp(() { diff --git a/packages/flutter_web_plugins/test/plugin_registry_test.dart b/packages/flutter_web_plugins/test/plugin_registry_test.dart index d33d88866a020..43d724527e94a 100644 --- a/packages/flutter_web_plugins/test/plugin_registry_test.dart +++ b/packages/flutter_web_plugins/test/plugin_registry_test.dart @@ -5,7 +5,7 @@ @TestOn('chrome') // Uses web-only Flutter SDK library; -import 'dart:ui' as ui; +import 'dart:ui_web' as ui_web; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -31,7 +31,7 @@ class TestPlugin { void main() { // Disabling tester emulation because this test relies on real message channel communication. - ui.debugEmulateFlutterTesterEnvironment = false; // ignore: undefined_prefixed_name + ui_web.debugEmulateFlutterTesterEnvironment = false; group('Plugin Registry', () { setUp(() { From d5623b520eb262495db6a66be441572fd61427e1 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Wed, 6 Sep 2023 12:10:14 -0700 Subject: [PATCH 26/58] [CP] handle exceptions raised while searching for configured android studio (#133602) cherry-pick for https://github.com/flutter/flutter/pull/133180 ## 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]. - [x] 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] All existing and new tests are passing. [Contributor Guide]: https://github.com/flutter/flutter/wiki/Tree-hygiene#overview [Tree Hygiene]: https://github.com/flutter/flutter/wiki/Tree-hygiene [test-exempt]: https://github.com/flutter/flutter/wiki/Tree-hygiene#tests [Flutter Style Guide]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo [Features we expect every widget to implement]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#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/wiki/Tree-hygiene#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/wiki/Chat Co-authored-by: Xilai Zhang --- .../lib/src/android/android_studio.dart | 63 ++++++++++++------- .../android/android_studio_test.dart | 45 +++++++++++++ 2 files changed, 87 insertions(+), 21 deletions(-) diff --git a/packages/flutter_tools/lib/src/android/android_studio.dart b/packages/flutter_tools/lib/src/android/android_studio.dart index f83fdd2463c04..72d987583bdfe 100644 --- a/packages/flutter_tools/lib/src/android/android_studio.dart +++ b/packages/flutter_tools/lib/src/android/android_studio.dart @@ -236,16 +236,7 @@ class AndroidStudio { /// Android Studio found at that location is always returned, even if it is /// invalid. static AndroidStudio? latestValid() { - final String? configuredStudioPath = globals.config.getValue('android-studio-dir') as String?; - if (configuredStudioPath != null && !globals.fs.directory(configuredStudioPath).existsSync()) { - throwToolExit(''' -Could not find the Android Studio installation at the manually configured path "$configuredStudioPath". -Please verify that the path is correct and update it by running this command: flutter config --android-studio-dir '' - -To have flutter search for Android Studio installations automatically, remove -the configured path by running this command: flutter config --android-studio-dir '' -'''); - } + final Directory? configuredStudioDir = _configuredDir(); // Find all available Studio installations. final List studios = allInstalled(); @@ -255,8 +246,8 @@ the configured path by running this command: flutter config --android-studio-dir final AndroidStudio? manuallyConfigured = studios .where((AndroidStudio studio) => studio.configuredPath != null && - configuredStudioPath != null && - _pathsAreEqual(studio.configuredPath!, configuredStudioPath)) + configuredStudioDir != null && + _pathsAreEqual(studio.configuredPath!, configuredStudioDir.path)) .firstOrNull; if (manuallyConfigured != null) { @@ -323,16 +314,14 @@ the configured path by running this command: flutter config --android-studio-dir )); } - final String? configuredStudioDir = globals.config.getValue('android-studio-dir') as String?; - FileSystemEntity? configuredStudioDirAsEntity; + Directory? configuredStudioDir = _configuredDir(); if (configuredStudioDir != null) { - configuredStudioDirAsEntity = globals.fs.directory(configuredStudioDir); - if (configuredStudioDirAsEntity.basename == 'Contents') { - configuredStudioDirAsEntity = configuredStudioDirAsEntity.parent; + if (configuredStudioDir.basename == 'Contents') { + configuredStudioDir = configuredStudioDir.parent; } if (!candidatePaths - .any((FileSystemEntity e) => _pathsAreEqual(e.path, configuredStudioDirAsEntity!.path))) { - candidatePaths.add(configuredStudioDirAsEntity); + .any((FileSystemEntity e) => _pathsAreEqual(e.path, configuredStudioDir!.path))) { + candidatePaths.add(configuredStudioDir); } } @@ -357,13 +346,13 @@ the configured path by running this command: flutter config --android-studio-dir return candidatePaths .map((FileSystemEntity e) { - if (configuredStudioDirAsEntity == null) { + if (configuredStudioDir == null) { return AndroidStudio.fromMacOSBundle(e.path); } return AndroidStudio.fromMacOSBundle( e.path, - configuredPath: _pathsAreEqual(configuredStudioDirAsEntity.path, e.path) ? configuredStudioDir : null, + configuredPath: _pathsAreEqual(configuredStudioDir.path, e.path) ? configuredStudioDir.path : null, ); }) .whereType() @@ -493,6 +482,38 @@ the configured path by running this command: flutter config --android-studio-dir return studios; } + /// Gets the Android Studio install directory set by the user, if it is configured. + /// + /// The returned [Directory], if not null, is guaranteed to have existed during + /// this function's execution. + static Directory? _configuredDir() { + final String? configuredPath = globals.config.getValue('android-studio-dir') as String?; + if (configuredPath == null) { + return null; + } + final Directory result = globals.fs.directory(configuredPath); + + bool? configuredStudioPathExists; + String? exceptionMessage; + try { + configuredStudioPathExists = result.existsSync(); + } on FileSystemException catch (e) { + exceptionMessage = e.toString(); + } + + if (configuredStudioPathExists == false || exceptionMessage != null) { + throwToolExit(''' +Could not find the Android Studio installation at the manually configured path "$configuredPath". +${exceptionMessage == null ? '' : 'Encountered exception: $exceptionMessage\n\n'} +Please verify that the path is correct and update it by running this command: flutter config --android-studio-dir '' +To have flutter search for Android Studio installations automatically, remove +the configured path by running this command: flutter config --android-studio-dir +'''); + } + + return result; + } + static String? extractStudioPlistValueWithMatcher(String plistValue, RegExp keyMatcher) { return keyMatcher.stringMatch(plistValue)?.split('=').last.trim().replaceAll('"', ''); } diff --git a/packages/flutter_tools/test/general.shard/android/android_studio_test.dart b/packages/flutter_tools/test/general.shard/android/android_studio_test.dart index ee018bcc363ed..cc8d8c2f36481 100644 --- a/packages/flutter_tools/test/general.shard/android/android_studio_test.dart +++ b/packages/flutter_tools/test/general.shard/android/android_studio_test.dart @@ -11,6 +11,7 @@ import 'package:flutter_tools/src/base/platform.dart'; import 'package:flutter_tools/src/base/version.dart'; import 'package:flutter_tools/src/globals.dart' as globals; import 'package:flutter_tools/src/ios/plist_parser.dart'; +import 'package:path/path.dart' show Context; // flutter_ignore: package_path_import -- We only use Context as an interface. import 'package:test/fake.dart'; import '../../src/common.dart'; @@ -1287,6 +1288,20 @@ void main() { Platform: () => platform, ProcessManager: () => FakeProcessManager.any(), }); + + testUsingContext('handles file system exception when checking for explicitly configured Android Studio install', () { + const String androidStudioDir = '/Users/Dash/Desktop/android-studio'; + config.setValue('android-studio-dir', androidStudioDir); + + expect(() => AndroidStudio.latestValid(), + throwsToolExit(message: RegExp(r'[.\s\S]*Could not find[.\s\S]*FileSystemException[.\s\S]*'))); + }, overrides: { + Config: () => config, + Platform: () => platform, + FileSystem: () => _FakeFileSystem(), + FileSystemUtils: () => _FakeFsUtils(), + ProcessManager: () => FakeProcessManager.any(), + }); }); } @@ -1298,3 +1313,33 @@ class FakePlistUtils extends Fake implements PlistParser { return fileContents[plistFilePath]!; } } + +class _FakeFileSystem extends Fake implements FileSystem { + @override + Directory directory(dynamic path) { + return _NonExistentDirectory(); + } + + @override + Context get path { + return MemoryFileSystem.test().path; + } +} + +class _NonExistentDirectory extends Fake implements Directory { + @override + bool existsSync() { + throw const FileSystemException('OS Error: Filename, directory name, or volume label syntax is incorrect.'); + } + + @override + String get path => ''; + + @override + Directory get parent => _NonExistentDirectory(); +} + +class _FakeFsUtils extends Fake implements FileSystemUtils { + @override + String get homeDirPath => '/home/'; +} From 95c954ef51520cd2e3fd2dba5d436ba97749458a Mon Sep 17 00:00:00 2001 From: Xilai Zhang Date: Wed, 6 Sep 2023 12:50:24 -0700 Subject: [PATCH 27/58] [release cherrypick] Remove cirrus tests from the flutter framework (#134157) cp: https://github.com/flutter/flutter/pull/133575 Cirrus tests are removed in favor of the LUCI ones. context: https://github.com/flutter/flutter/pull/133602#issuecomment-1698324270 Co-authored-by: godofredoc --- .cirrus.yml | 158 ---------------------------------------------------- 1 file changed, 158 deletions(-) delete mode 100644 .cirrus.yml diff --git a/.cirrus.yml b/.cirrus.yml deleted file mode 100644 index cdcba477e7631..0000000000000 --- a/.cirrus.yml +++ /dev/null @@ -1,158 +0,0 @@ -# CIRRUS CONFIGURATION FILE -# https://cirrus-ci.org/guide/writing-tasks/ - -environment: - # For details about environment variables used in Cirrus, including how encrypted variables work, - # see https://cirrus-ci.org/guide/writing-tasks/#environment-variables - # We change Flutter's directory to include a space in its name (see $CIRRUS_WORKING_DIR) so that - # we constantly test path names with spaces in them. The FLUTTER_SDK_PATH_WITH_SPACE variable must - # therefore have a space in it. - FLUTTER_SDK_PATH_WITH_SPACE: "flutter sdk" - # We force BOT to true so that all our tools know we're in a CI environment. This avoids any - # dependency on precisely how Cirrus is detected by our tools. - BOT: "true" - -gcp_credentials: ENCRYPTED[!9c8e92e8da192ce2a51b7d4ed9948c4250d0bae3660193d9b901196c9692abbebe784d4a32e9f38b328571d65f6e7aed!] - -# LINUX SHARDS -task: - gke_container: - dockerfile: "dev/ci/docker_linux/Dockerfile" - builder_image_name: docker-builder-linux # gce vm image - builder_image_project: flutter-cirrus - cluster_name: test-cluster - zone: us-central1-a - namespace: default - cpu: $CPU - memory: $MEMORY - use_in_memory_disk: $USE_IN_MEMORY_DISK - environment: - # We shrink our default resource requirement as much as possible because that way we are more - # likely to get scheduled. We require 4G of RAM because most of the shards (all but one as of - # October 2019) just get OOM-killed with less. Some shards may need more. When increasing the - # requirements for select shards, please leave a comment on those shards saying when you - # increased the requirements, what numbers you tried, and what the results were. - CPU: 1 # 0.1-8 without compute credits, 0.1-30 with (yes, you can go fractional) - MEMORY: 4G # 256M-24G without compute credits, 256M-90G with - CIRRUS_WORKING_DIR: "/tmp/$FLUTTER_SDK_PATH_WITH_SPACE" - CIRRUS_DOCKER_CONTEXT: "dev/" - PATH: "$CIRRUS_WORKING_DIR/bin:$CIRRUS_WORKING_DIR/bin/cache/dart-sdk/bin:$PATH" - ANDROID_SDK_ROOT: "/opt/android_sdk" - SHOULD_UPDATE_PACKAGES: 'TRUE' # can be overridden at the task level - USE_IN_MEMORY_DISK: false - pub_cache: - folder: $HOME/.pub-cache - fingerprint_script: echo $OS; grep -r --include=pubspec.yaml 'PUBSPEC CHECKSUM' "$CIRRUS_WORKING_DIR" - reupload_on_changes: false - flutter_pkg_cache: - folder: bin/cache/pkg - fingerprint_script: echo $OS; cat bin/internal/*.version - reupload_on_changes: false - artifacts_cache: - folder: bin/cache/artifacts - fingerprint_script: echo $OS; cat bin/internal/*.version - reupload_on_changes: false - setup_script: - - date - - git clean -xffd --exclude=bin/cache/ - - git fetch origin - - git fetch origin master # To set FETCH_HEAD, so that "git merge-base" works. - - flutter config --no-analytics - - if [ "$SHOULD_UPDATE_PACKAGES" == TRUE ]; then flutter update-packages; fi - - flutter doctor -v - - ./dev/bots/accept_android_sdk_licenses.sh - - date - on_failure: - failure_script: - - date - - which flutter - matrix: - - name: analyze-linux # linux-only - only_if: "$CIRRUS_PR != '' && $CIRRUS_BASE_BRANCH == 'master'" - environment: - # Empirically, the analyze-linux shard runs surprisingly fast (under 15 minutes) with just 1 - # CPU. We noticed OOM failures with 6GB 4/2020, so we increased the memory. - CPU: 1 - MEMORY: 8G - script: - - dart --enable-asserts ./dev/bots/analyze.dart - - - name: framework_tests-widgets-linux - only_if: "changesInclude('.cirrus.yml', 'dev/**', 'packages/flutter/**', 'packages/flutter_test/**', 'packages/flutter_tools/lib/src/test/**', 'bin/**') && $CIRRUS_PR != '' && $CIRRUS_BASE_BRANCH == 'master'" - environment: - # We use 3 CPUs because that's the minimum required to get framework_tests-widgets-linux - # running fast enough that it is not the long pole, as of October 2019. - CPU: 3 - script: - - dart --enable-asserts ./dev/bots/test.dart - - - name: framework_tests-libraries-linux - only_if: "changesInclude('.cirrus.yml', 'dev/**', 'packages/flutter/**', 'packages/flutter_test/**', 'packages/flutter_tools/lib/src/test/**', 'bin/**') && $CIRRUS_PR != '' && $CIRRUS_BASE_BRANCH == 'master'" - environment: - # We use 3 CPUs because that's the minimum required to get the - # framework_tests-libraries-linux shard running fast enough that it is not the long pole, as - # of October 2019. - CPU: 3 - script: - - dart --enable-asserts ./dev/bots/test.dart - - - name: framework_tests-misc-linux - # this includes the tests for directories in dev/ - only_if: "changesInclude('.cirrus.yml', 'dev/**', 'packages/flutter/**', 'packages/flutter_goldens/**', 'packages/flutter_test/**', 'packages/flutter_tools/lib/src/test/**', 'bin/**') && $CIRRUS_PR != '' && $CIRRUS_BASE_BRANCH == 'master'" - environment: - # We use 3 CPUs because that's the minimum required to get framework_tests-misc-linux - # running fast enough that it is not the long pole, as of October 2019. - CPU: 3 - script: - - dart --enable-asserts ./dev/bots/test.dart - - - name: tool_tests-general-linux - only_if: "changesInclude('.cirrus.yml', 'dev/**', 'packages/flutter_tools/**', 'bin/**') && $CIRRUS_PR != '' && $CIRRUS_BASE_BRANCH == 'master'" - environment: - # As of November 2019, the tool_tests-general-linux shard got faster with more CPUs up to 4 - # CPUs, and needed at least 10G of RAM to not run out of memory. - CPU: 4 - MEMORY: 10G - SHOULD_UPDATE_PACKAGES: "FALSE" - script: - - (cd packages/flutter_tools; dart pub get) - - (cd packages/flutter_tools/test/data/asset_test/main; dart pub get) - - (cd packages/flutter_tools/test/data/asset_test/font; dart pub get) - - (cd dev/bots; dart pub get) - - dart --enable-asserts ./dev/bots/test.dart - - - name: tool_tests-commands-linux - only_if: "changesInclude('.cirrus.yml', 'dev/**', 'packages/flutter_tools/**', 'bin/**') && $CIRRUS_PR != '' && $CIRRUS_BASE_BRANCH == 'master'" - environment: - # As of October 2019, the tool_tests-commands-linux shard got faster with more CPUs up to 6 - # CPUs, and needed at least 8G of RAM to not run out of memory. - # Increased to 10GB on 19th Nov 2019 due to significant number of OOMKilled failures on PR builds. - CPU: 6 - MEMORY: 10G - SHOULD_UPDATE_PACKAGES: "FALSE" - script: - - (cd packages/flutter_tools; dart pub get) - - (cd dev/bots; dart pub get) - - dart --enable-asserts ./dev/bots/test.dart - - - name: docs-linux # linux-only - environment: - CPU: 4 - MEMORY: 12G - only_if: "$CIRRUS_PR != '' && $CIRRUS_BASE_BRANCH == 'master'" - script: - - ./dev/bots/docs.sh - - - name: customer_testing-linux - only_if: "$CIRRUS_PR != '' && $CIRRUS_BASE_BRANCH == 'master'" - environment: - # Empirically, this shard runs fine at 1 CPU and 4G RAM as of October 2019. We will probably - # want to grow this container when we invite people to add their tests in large numbers. - SHOULD_UPDATE_PACKAGES: "FALSE" - script: - # Cirrus doesn't give us the master branch, so we have to fetch it for ourselves, - # otherwise we won't be able to figure out how old or new our current branch is. - - git config user.email "cirrus-bot@invalid" - - git fetch origin master:master - # The actual logic is in a shell script so that it can be shared between CIs. - - (cd dev/customer_testing/; ./ci.sh) From 2524052335ec76bb03e04ede244b071f1b86d190 Mon Sep 17 00:00:00 2001 From: Xilai Zhang Date: Wed, 6 Sep 2023 14:32:31 -0700 Subject: [PATCH 28/58] [flutter_releases] Flutter stable 3.13.0 Framework Cherrypicks (#134163) # Flutter stable 3.13.0 Framework ## Scheduled Cherrypicks --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 4806d42d7936a..892b2d54fdfc9 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -b20183e04096094bcc37d9cde2a4b96f5cc684cf +b8d35810e91ab8fc39ba5e7a41bff6f697e8e3a8 From a625ad4ea94925edbe83820fa9f0ddf677a1f3e2 Mon Sep 17 00:00:00 2001 From: Tess Strickland Date: Sun, 10 Sep 2023 02:15:21 +0200 Subject: [PATCH 29/58] [CP] Update vm_snapshot_analysis to 0.7.6 on stable (#133657) Partially cherry picks https://github.com/flutter/flutter/commit/3b8f6c4020a70a03267ab0ab9e25e83b26341082, in particular it only performs the update of `vm_snapshot_analysis` to 0.7.6. Issues fixed: * https://github.com/flutter/flutter/issues/132695 --- dev/forbidden_from_release_tests/pubspec.yaml | 4 ++-- packages/flutter_tools/pubspec.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dev/forbidden_from_release_tests/pubspec.yaml b/dev/forbidden_from_release_tests/pubspec.yaml index 48beef50b20e7..38a767f90b6d1 100644 --- a/dev/forbidden_from_release_tests/pubspec.yaml +++ b/dev/forbidden_from_release_tests/pubspec.yaml @@ -10,10 +10,10 @@ dependencies: package_config: 2.1.0 path: 1.8.3 process: 4.2.4 - vm_snapshot_analysis: 0.7.2 + vm_snapshot_analysis: 0.7.6 collection: 1.17.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" meta: 1.9.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" platform: 3.1.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" -# PUBSPEC CHECKSUM: 5a67 +# PUBSPEC CHECKSUM: 5e6b diff --git a/packages/flutter_tools/pubspec.yaml b/packages/flutter_tools/pubspec.yaml index 483a9b0213c2a..d658af9149643 100644 --- a/packages/flutter_tools/pubspec.yaml +++ b/packages/flutter_tools/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: yaml: 3.1.2 native_stack_traces: 0.5.6 shelf: 1.4.1 - vm_snapshot_analysis: 0.7.2 + vm_snapshot_analysis: 0.7.6 uuid: 3.0.7 web_socket_channel: 2.4.0 stream_channel: 2.1.1 @@ -106,4 +106,4 @@ dartdoc: # Exclude this package from the hosted API docs. nodoc: true -# PUBSPEC CHECKSUM: 4bb3 +# PUBSPEC CHECKSUM: bab7 From b0daa737b75b007b4777e5cc2bd42b1b18a6fd4f Mon Sep 17 00:00:00 2001 From: godofredoc Date: Tue, 12 Sep 2023 17:01:41 -0700 Subject: [PATCH 30/58] Roll engine to 17a711a7765a198264b20d8c4983f2f6f1271271 (#134584) Rolls engine to: 17a711a7765a198264b20d8c4983f2f6f1271271 --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 892b2d54fdfc9..b7b58312c547a 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -b8d35810e91ab8fc39ba5e7a41bff6f697e8e3a8 +17a711a7765a198264b20d8c4983f2f6f1271271 From 367f9ea16bfae1ca451b9cc27c1366870b187ae2 Mon Sep 17 00:00:00 2001 From: Kevin Chisholm Date: Tue, 12 Sep 2023 23:27:53 -0500 Subject: [PATCH 31/58] [flutter_releases] Flutter stable 3.13.4 Framework Cherrypicks (#134599) # Flutter stable 3.13.4 Framework ## Scheduled Cherrypicks --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index b7b58312c547a..71ff5d96899dd 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -17a711a7765a198264b20d8c4983f2f6f1271271 +9064459a8b0dcd32877107f6002cc429a71659d1 From 0776843125e1c938427c1c70dfbacd6faaeedb44 Mon Sep 17 00:00:00 2001 From: Victoria Ashworth <15619084+vashworth@users.noreply.github.com> Date: Fri, 15 Sep 2023 17:26:18 -0500 Subject: [PATCH 32/58] [CP 3.13] Set the CONFIGURATION_BUILD_DIR in generated xcconfig when debugging core device (#134824) Original PR: https://github.com/flutter/flutter/pull/134493 --- .ci.yaml | 11 +++ TESTOWNERS | 1 + .../microbenchmarks_ios_xcode_debug.dart | 21 +++++ dev/devicelab/lib/microbenchmarks.dart | 6 ++ dev/devicelab/lib/tasks/microbenchmarks.dart | 7 +- .../flutter_tools/lib/src/ios/devices.dart | 30 ++++++- packages/flutter_tools/lib/src/ios/mac.dart | 2 - .../lib/src/ios/xcode_build_settings.dart | 13 +-- .../ios_device_start_nonprebuilt_test.dart | 82 +++++++++++++++++++ .../general.shard/ios/xcodeproj_test.dart | 41 ++-------- 10 files changed, 170 insertions(+), 44 deletions(-) create mode 100644 dev/devicelab/bin/tasks/microbenchmarks_ios_xcode_debug.dart diff --git a/.ci.yaml b/.ci.yaml index 9116008c556af..64a4de522fd43 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -3868,6 +3868,17 @@ targets: ["devicelab", "ios", "mac"] task_name: microbenchmarks_ios + # TODO(vashworth): Remove after Xcode 15 and iOS 17 are in CI (https://github.com/flutter/flutter/issues/132128) + - name: Mac_ios microbenchmarks_ios_xcode_debug + recipe: devicelab/devicelab_drone + presubmit: false + timeout: 60 + properties: + tags: > + ["devicelab", "ios", "mac"] + task_name: microbenchmarks_ios_xcode_debug + bringup: true + - name: Mac_ios native_platform_view_ui_tests_ios recipe: devicelab/devicelab_drone presubmit: false diff --git a/TESTOWNERS b/TESTOWNERS index 81fdb38686394..18b3626990c39 100644 --- a/TESTOWNERS +++ b/TESTOWNERS @@ -188,6 +188,7 @@ /dev/devicelab/bin/tasks/large_image_changer_perf_ios.dart @zanderso @flutter/engine /dev/devicelab/bin/tasks/macos_chrome_dev_mode.dart @zanderso @flutter/tool /dev/devicelab/bin/tasks/microbenchmarks_ios.dart @cyanglaz @flutter/engine +/dev/devicelab/bin/tasks/microbenchmarks_ios_xcode_debug.dart @vashworth @flutter/engine /dev/devicelab/bin/tasks/native_platform_view_ui_tests_ios.dart @hellohuanlin @flutter/ios /dev/devicelab/bin/tasks/new_gallery_ios__transition_perf.dart @zanderso @flutter/engine /dev/devicelab/bin/tasks/new_gallery_skia_ios__transition_perf.dart @zanderso @flutter/engine diff --git a/dev/devicelab/bin/tasks/microbenchmarks_ios_xcode_debug.dart b/dev/devicelab/bin/tasks/microbenchmarks_ios_xcode_debug.dart new file mode 100644 index 0000000000000..3373a683672e8 --- /dev/null +++ b/dev/devicelab/bin/tasks/microbenchmarks_ios_xcode_debug.dart @@ -0,0 +1,21 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_devicelab/framework/devices.dart'; +import 'package:flutter_devicelab/framework/framework.dart'; +import 'package:flutter_devicelab/tasks/microbenchmarks.dart'; + +/// Runs microbenchmarks on iOS. +Future main() async { + // XcodeDebug workflow is used for CoreDevices (iOS 17+ and Xcode 15+). Use + // FORCE_XCODE_DEBUG environment variable to force the use of XcodeDebug + // workflow in CI to test from older versions since devicelab has not yet been + // updated to iOS 17 and Xcode 15. + deviceOperatingSystem = DeviceOperatingSystem.ios; + await task(createMicrobenchmarkTask( + environment: { + 'FORCE_XCODE_DEBUG': 'true', + }, + )); +} diff --git a/dev/devicelab/lib/microbenchmarks.dart b/dev/devicelab/lib/microbenchmarks.dart index 0cf3d8192466e..451be27b1a550 100644 --- a/dev/devicelab/lib/microbenchmarks.dart +++ b/dev/devicelab/lib/microbenchmarks.dart @@ -64,6 +64,12 @@ Future> readJsonResults(Process process) { // See https://github.com/flutter/flutter/issues/19208 process.stdin.write('q'); await process.stdin.flush(); + + // Give the process a couple of seconds to exit and run shutdown hooks + // before sending kill signal. + // TODO(fujino): https://github.com/flutter/flutter/issues/134566 + await Future.delayed(const Duration(seconds: 2)); + // Also send a kill signal in case the `q` above didn't work. process.kill(ProcessSignal.sigint); try { diff --git a/dev/devicelab/lib/tasks/microbenchmarks.dart b/dev/devicelab/lib/tasks/microbenchmarks.dart index 967bb58dfe607..6bd01aac1d2e8 100644 --- a/dev/devicelab/lib/tasks/microbenchmarks.dart +++ b/dev/devicelab/lib/tasks/microbenchmarks.dart @@ -15,7 +15,10 @@ import '../microbenchmarks.dart'; /// Creates a device lab task that runs benchmarks in /// `dev/benchmarks/microbenchmarks` reports results to the dashboard. -TaskFunction createMicrobenchmarkTask({bool? enableImpeller}) { +TaskFunction createMicrobenchmarkTask({ + bool? enableImpeller, + Map environment = const {}, +}) { return () async { final Device device = await devices.workingDevice; await device.unlock(); @@ -41,9 +44,9 @@ TaskFunction createMicrobenchmarkTask({bool? enableImpeller}) { return startFlutter( 'run', options: options, + environment: environment, ); }); - return readJsonResults(flutterProcess); } diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index 85d89aceeb274..0316576fb6e53 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -34,6 +34,7 @@ import 'ios_deploy.dart'; import 'ios_workflow.dart'; import 'iproxy.dart'; import 'mac.dart'; +import 'xcode_build_settings.dart'; import 'xcode_debug.dart'; import 'xcodeproj.dart'; @@ -500,7 +501,6 @@ class IOSDevice extends Device { targetOverride: mainPath, activeArch: cpuArchitecture, deviceID: id, - isCoreDevice: isCoreDevice || forceXcodeDebugWorkflow, ); if (!buildResult.success) { _logger.printError('Could not build the precompiled application for the device.'); @@ -573,6 +573,7 @@ class IOSDevice extends Device { debuggingOptions: debuggingOptions, package: package, launchArguments: launchArguments, + mainPath: mainPath, discoveryTimeout: discoveryTimeout, shutdownHooks: shutdownHooks ?? globals.shutdownHooks, ) ? 0 : 1; @@ -737,6 +738,7 @@ class IOSDevice extends Device { required DebuggingOptions debuggingOptions, required IOSApp package, required List launchArguments, + required String? mainPath, required ShutdownHooks shutdownHooks, @visibleForTesting Duration? discoveryTimeout, }) async { @@ -775,6 +777,7 @@ class IOSDevice extends Device { }); XcodeDebugProject debugProject; + final FlutterProject flutterProject = FlutterProject.current(); if (package is PrebuiltIOSApp) { debugProject = await _xcodeDebug.createXcodeProjectWithCustomBundle( @@ -783,6 +786,19 @@ class IOSDevice extends Device { verboseLogging: _logger.isVerbose, ); } else if (package is BuildableIOSApp) { + // Before installing/launching/debugging with Xcode, update the build + // settings to use a custom configuration build directory so Xcode + // knows where to find the app bundle to launch. + final Directory bundle = _fileSystem.directory( + package.deviceBundlePath, + ); + await updateGeneratedXcodeProperties( + project: flutterProject, + buildInfo: debuggingOptions.buildInfo, + targetOverride: mainPath, + configurationBuildDir: bundle.parent.absolute.path, + ); + final IosProject project = package.project; final XcodeProjectInfo? projectInfo = await project.projectInfo(); if (projectInfo == null) { @@ -823,6 +839,18 @@ class IOSDevice extends Device { shutdownHooks.addShutdownHook(() => _xcodeDebug.exit(force: true)); } + if (package is BuildableIOSApp) { + // After automating Xcode, reset the Generated settings to not include + // the custom configuration build directory. This is to prevent + // confusion if the project is later ran via Xcode rather than the + // Flutter CLI. + await updateGeneratedXcodeProperties( + project: flutterProject, + buildInfo: debuggingOptions.buildInfo, + targetOverride: mainPath, + ); + } + return debugSuccess; } } diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart index d8d9188109eb5..ad69abd211c4c 100644 --- a/packages/flutter_tools/lib/src/ios/mac.dart +++ b/packages/flutter_tools/lib/src/ios/mac.dart @@ -132,7 +132,6 @@ Future buildXcodeProject({ DarwinArch? activeArch, bool codesign = true, String? deviceID, - bool isCoreDevice = false, bool configOnly = false, XcodeBuildAction buildAction = XcodeBuildAction.build, }) async { @@ -241,7 +240,6 @@ Future buildXcodeProject({ project: project, targetOverride: targetOverride, buildInfo: buildInfo, - usingCoreDevice: isCoreDevice, ); await processPodsIfNeeded(project.ios, getIosBuildDirectory(), buildInfo.mode); if (configOnly) { diff --git a/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart b/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart index 0e7c42b9f34d2..eeda85a63bf9f 100644 --- a/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart +++ b/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart @@ -35,7 +35,7 @@ Future updateGeneratedXcodeProperties({ String? targetOverride, bool useMacOSConfig = false, String? buildDirOverride, - bool usingCoreDevice = false, + String? configurationBuildDir, }) async { final List xcodeBuildSettings = await _xcodeBuildSettingsLines( project: project, @@ -43,7 +43,7 @@ Future updateGeneratedXcodeProperties({ targetOverride: targetOverride, useMacOSConfig: useMacOSConfig, buildDirOverride: buildDirOverride, - usingCoreDevice: usingCoreDevice, + configurationBuildDir: configurationBuildDir, ); _updateGeneratedXcodePropertiesFile( @@ -145,7 +145,7 @@ Future> _xcodeBuildSettingsLines({ String? targetOverride, bool useMacOSConfig = false, String? buildDirOverride, - bool usingCoreDevice = false, + String? configurationBuildDir, }) async { final List xcodeBuildSettings = []; @@ -174,9 +174,10 @@ Future> _xcodeBuildSettingsLines({ xcodeBuildSettings.add('FLUTTER_BUILD_NUMBER=$buildNumber'); // CoreDevices in debug and profile mode are launched, but not built, via Xcode. - // Set the BUILD_DIR so Xcode knows where to find the app bundle to launch. - if (usingCoreDevice && !buildInfo.isRelease) { - xcodeBuildSettings.add('BUILD_DIR=${globals.fs.path.absolute(getIosBuildDirectory())}'); + // Set the CONFIGURATION_BUILD_DIR so Xcode knows where to find the app + // bundle to launch. + if (configurationBuildDir != null) { + xcodeBuildSettings.add('CONFIGURATION_BUILD_DIR=$configurationBuildDir'); } final LocalEngineInfo? localEngineInfo = globals.artifacts?.localEngineInfo; diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart index d7f354cd6148a..00d19d1edeab3 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart @@ -519,6 +519,82 @@ void main() { Xcode: () => xcode, }); + testUsingContext('updates Generated.xcconfig before and after launch', () async { + final Completer debugStartedCompleter = Completer(); + final Completer debugEndedCompleter = Completer(); + final IOSDevice iosDevice = setUpIOSDevice( + fileSystem: fileSystem, + processManager: FakeProcessManager.any(), + logger: logger, + artifacts: artifacts, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug( + expectedProject: XcodeDebugProject( + scheme: 'Runner', + xcodeWorkspace: fileSystem.directory('/ios/Runner.xcworkspace'), + xcodeProject: fileSystem.directory('/ios/Runner.xcodeproj'), + ), + expectedDeviceId: '123', + expectedLaunchArguments: ['--enable-dart-profiling'], + debugStartedCompleter: debugStartedCompleter, + debugEndedCompleter: debugEndedCompleter, + ), + ); + + setUpIOSProject(fileSystem); + final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); + final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); + + final FakeDeviceLogReader deviceLogReader = FakeDeviceLogReader(); + + iosDevice.portForwarder = const NoOpDevicePortForwarder(); + iosDevice.setLogReader(buildableIOSApp, deviceLogReader); + + // Start writing messages to the log reader. + Timer.run(() { + deviceLogReader.addLine('Foo'); + deviceLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:456'); + }); + + final Future futureLaunchResult = iosDevice.startApp( + buildableIOSApp, + debuggingOptions: DebuggingOptions.enabled(const BuildInfo( + BuildMode.debug, + null, + buildName: '1.2.3', + buildNumber: '4', + treeShakeIcons: false, + )), + platformArgs: {}, + ); + + await debugStartedCompleter.future; + + // Validate CoreDevice build settings were used + final File config = fileSystem.directory('ios').childFile('Flutter/Generated.xcconfig'); + expect(config.existsSync(), isTrue); + + String contents = config.readAsStringSync(); + expect(contents, contains('CONFIGURATION_BUILD_DIR=/build/ios/iphoneos')); + + debugEndedCompleter.complete(); + + await futureLaunchResult; + + // Validate CoreDevice build settings were removed after launch + contents = config.readAsStringSync(); + expect(contents.contains('CONFIGURATION_BUILD_DIR'), isFalse); + }, overrides: { + ProcessManager: () => FakeProcessManager.any(), + FileSystem: () => fileSystem, + Logger: () => logger, + Platform: () => macPlatform, + XcodeProjectInterpreter: () => fakeXcodeProjectInterpreter, + Xcode: () => xcode, + }); + testUsingContext('fails when Xcode project is not found', () async { final IOSDevice iosDevice = setUpIOSDevice( fileSystem: fileSystem, @@ -750,6 +826,8 @@ class FakeXcodeDebug extends Fake implements XcodeDebug { this.expectedProject, this.expectedDeviceId, this.expectedLaunchArguments, + this.debugStartedCompleter, + this.debugEndedCompleter, }); final bool debugSuccess; @@ -757,6 +835,8 @@ class FakeXcodeDebug extends Fake implements XcodeDebug { final XcodeDebugProject? expectedProject; final String? expectedDeviceId; final List? expectedLaunchArguments; + final Completer? debugStartedCompleter; + final Completer? debugEndedCompleter; @override Future debugApp({ @@ -764,6 +844,7 @@ class FakeXcodeDebug extends Fake implements XcodeDebug { required String deviceId, required List launchArguments, }) async { + debugStartedCompleter?.complete(); if (expectedProject != null) { expect(project.scheme, expectedProject!.scheme); expect(project.xcodeWorkspace.path, expectedProject!.xcodeWorkspace.path); @@ -776,6 +857,7 @@ class FakeXcodeDebug extends Fake implements XcodeDebug { if (expectedLaunchArguments != null) { expect(expectedLaunchArguments, launchArguments); } + await debugEndedCompleter?.future; return debugSuccess; } } diff --git a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart index 3dfe9157d5189..b2f79385050dd 100644 --- a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart @@ -1308,66 +1308,41 @@ flutter: }); group('CoreDevice', () { - testUsingContext('sets BUILD_DIR for core devices in debug mode', () async { + testUsingContext('sets CONFIGURATION_BUILD_DIR when configurationBuildDir is set', () async { const BuildInfo buildInfo = BuildInfo.debug; final FlutterProject project = FlutterProject.fromDirectoryTest(fs.directory('path/to/project')); await updateGeneratedXcodeProperties( project: project, buildInfo: buildInfo, - useMacOSConfig: true, - usingCoreDevice: true, + configurationBuildDir: 'path/to/project/build/ios/iphoneos' ); - final File config = fs.file('path/to/project/macos/Flutter/ephemeral/Flutter-Generated.xcconfig'); - expect(config.existsSync(), isTrue); - - final String contents = config.readAsStringSync(); - expect(contents, contains('\nBUILD_DIR=/build/ios\n')); - }, overrides: { - Artifacts: () => localIosArtifacts, - Platform: () => macOS, - FileSystem: () => fs, - ProcessManager: () => FakeProcessManager.any(), - XcodeProjectInterpreter: () => xcodeProjectInterpreter, - }); - - testUsingContext('does not set BUILD_DIR for core devices in release mode', () async { - const BuildInfo buildInfo = BuildInfo.release; - final FlutterProject project = FlutterProject.fromDirectoryTest(fs.directory('path/to/project')); - await updateGeneratedXcodeProperties( - project: project, - buildInfo: buildInfo, - useMacOSConfig: true, - usingCoreDevice: true, - ); - - final File config = fs.file('path/to/project/macos/Flutter/ephemeral/Flutter-Generated.xcconfig'); + final File config = fs.file('path/to/project/ios/Flutter/Generated.xcconfig'); expect(config.existsSync(), isTrue); final String contents = config.readAsStringSync(); - expect(contents.contains('\nBUILD_DIR'), isFalse); + expect(contents, contains('CONFIGURATION_BUILD_DIR=path/to/project/build/ios/iphoneos')); }, overrides: { Artifacts: () => localIosArtifacts, - Platform: () => macOS, + // Platform: () => macOS, FileSystem: () => fs, ProcessManager: () => FakeProcessManager.any(), XcodeProjectInterpreter: () => xcodeProjectInterpreter, }); - testUsingContext('does not set BUILD_DIR for non core devices', () async { + testUsingContext('does not set CONFIGURATION_BUILD_DIR when configurationBuildDir is not set', () async { const BuildInfo buildInfo = BuildInfo.debug; final FlutterProject project = FlutterProject.fromDirectoryTest(fs.directory('path/to/project')); await updateGeneratedXcodeProperties( project: project, buildInfo: buildInfo, - useMacOSConfig: true, ); - final File config = fs.file('path/to/project/macos/Flutter/ephemeral/Flutter-Generated.xcconfig'); + final File config = fs.file('path/to/project/ios/Flutter/Generated.xcconfig'); expect(config.existsSync(), isTrue); final String contents = config.readAsStringSync(); - expect(contents.contains('\nBUILD_DIR'), isFalse); + expect(contents.contains('CONFIGURATION_BUILD_DIR'), isFalse); }, overrides: { Artifacts: () => localIosArtifacts, Platform: () => macOS, From 12fccda598477eddd19f93040a1dba24f915b9be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20S=20Guerrero?= Date: Tue, 19 Sep 2023 13:56:11 -0700 Subject: [PATCH 33/58] Cherrypicks flutter 3.13 candidate.0 (#135048) roll engine --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 71ff5d96899dd..76faf4c62c6f2 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -9064459a8b0dcd32877107f6002cc429a71659d1 +bd986c5ed20a62dc34b7718c50abc782beae4c33 From ead455963c12b453cdb2358cad34969c76daf180 Mon Sep 17 00:00:00 2001 From: Casey Hillers Date: Tue, 26 Sep 2023 18:28:17 -0700 Subject: [PATCH 34/58] [flutter_releases] Flutter stable 3.13.6 Framework Cherrypicks (#135532) # Flutter stable 3.13.6 Framework ## Scheduled Cherrypicks --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 76faf4c62c6f2..7efc15d95f56f 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -bd986c5ed20a62dc34b7718c50abc782beae4c33 +a794cf2681c6c9fe7b260e0e84de96298dc9c18b From 2f708eb8396e362e280fac22cf171c2cb467343c Mon Sep 17 00:00:00 2001 From: Victoria Ashworth <15619084+vashworth@users.noreply.github.com> Date: Mon, 9 Oct 2023 09:58:08 -0500 Subject: [PATCH 35/58] [CP] Wait for CONFIGURATION_BUILD_DIR to update when debugging with Xcode (#135609) Original PR: https://github.com/flutter/flutter/pull/135444 --- packages/flutter_tools/bin/xcode_debug.js | 171 ++++++++++++++++-- .../flutter_tools/lib/src/ios/devices.dart | 26 +-- .../lib/src/ios/xcode_debug.dart | 13 ++ .../ios_device_start_nonprebuilt_test.dart | 3 + .../ios/ios_device_start_prebuilt_test.dart | 4 + .../general.shard/ios/xcode_debug_test.dart | 31 +++- 6 files changed, 215 insertions(+), 33 deletions(-) diff --git a/packages/flutter_tools/bin/xcode_debug.js b/packages/flutter_tools/bin/xcode_debug.js index 25f16a27298aa..611ba1ba8fea4 100644 --- a/packages/flutter_tools/bin/xcode_debug.js +++ b/packages/flutter_tools/bin/xcode_debug.js @@ -61,6 +61,11 @@ class CommandArguments { this.xcodePath = this.validatedStringArgument('--xcode-path', parsedArguments['--xcode-path']); this.projectPath = this.validatedStringArgument('--project-path', parsedArguments['--project-path']); + this.projectName = this.validatedStringArgument('--project-name', parsedArguments['--project-name']); + this.expectedConfigurationBuildDir = this.validatedStringArgument( + '--expected-configuration-build-dir', + parsedArguments['--expected-configuration-build-dir'], + ); this.workspacePath = this.validatedStringArgument('--workspace-path', parsedArguments['--workspace-path']); this.targetDestinationId = this.validatedStringArgument('--device-id', parsedArguments['--device-id']); this.targetSchemeName = this.validatedStringArgument('--scheme', parsedArguments['--scheme']); @@ -92,42 +97,76 @@ class CommandArguments { } /** - * Validates the flag is allowed for the current command. + * Returns map of commands to map of allowed arguments. For each command, if + * an argument flag is a key, than that flag is allowed for that command. If + * the value for the key is true, then it is required for the command. * - * @param {!string} flag - * @param {?string} value - * @returns {!bool} - * @throws Will throw an error if the flag is not allowed for the current - * command and the value is not null, undefined, or empty. + * @returns {!string} Map of commands to allowed and optionally required + * arguments. */ - isArgumentAllowed(flag, value) { - const allowedArguments = { - 'common': { + argumentSettings() { + return { + 'check-workspace-opened': { '--xcode-path': true, '--project-path': true, '--workspace-path': true, - '--verbose': true, + '--verbose': false, }, - 'check-workspace-opened': {}, 'debug': { + '--xcode-path': true, + '--project-path': true, + '--workspace-path': true, + '--project-name': true, + '--expected-configuration-build-dir': false, '--device-id': true, '--scheme': true, '--skip-building': true, '--launch-args': true, + '--verbose': false, }, 'stop': { + '--xcode-path': true, + '--project-path': true, + '--workspace-path': true, '--close-window': true, '--prompt-to-save': true, + '--verbose': false, }, - } + }; + } - const isAllowed = allowedArguments['common'][flag] === true || allowedArguments[this.command][flag] === true; + /** + * Validates the flag is allowed for the current command. + * + * @param {!string} flag + * @param {?string} value + * @returns {!bool} + * @throws Will throw an error if the flag is not allowed for the current + * command and the value is not null, undefined, or empty. + */ + isArgumentAllowed(flag, value) { + const isAllowed = this.argumentSettings()[this.command].hasOwnProperty(flag); if (isAllowed === false && (value != null && value !== '')) { throw `The flag ${flag} is not allowed for the command ${this.command}.`; } return isAllowed; } + /** + * Validates required flag has a value. + * + * @param {!string} flag + * @param {?string} value + * @throws Will throw an error if the flag is required for the current + * command and the value is not null, undefined, or empty. + */ + validateRequiredArgument(flag, value) { + const isRequired = this.argumentSettings()[this.command][flag] === true; + if (isRequired === true && (value == null || value === '')) { + throw `Missing value for ${flag}`; + } + } + /** * Parses the command line arguments into an object. * @@ -182,9 +221,7 @@ class CommandArguments { if (this.isArgumentAllowed(flag, value) === false) { return null; } - if (value == null || value === '') { - throw `Missing value for ${flag}`; - } + this.validateRequiredArgument(flag, value); return value; } @@ -226,9 +263,7 @@ class CommandArguments { if (this.isArgumentAllowed(flag, value) === false) { return null; } - if (value == null || value === '') { - throw `Missing value for ${flag}`; - } + this.validateRequiredArgument(flag, value); try { return JSON.parse(value); } catch (e) { @@ -347,6 +382,15 @@ function debugApp(xcode, args) { return new FunctionResult(null, destinationResult.error) } + // If expectedConfigurationBuildDir is available, ensure that it matches the + // build settings. + if (args.expectedConfigurationBuildDir != null && args.expectedConfigurationBuildDir !== '') { + const updateResult = waitForConfigurationBuildDirToUpdate(targetWorkspace, args); + if (updateResult.error != null) { + return new FunctionResult(null, updateResult.error); + } + } + try { // Documentation from the Xcode Script Editor dictionary indicates that the // `debug` function has a parameter called `runDestinationSpecifier` which @@ -528,3 +572,92 @@ function stopApp(xcode, args) { } return new FunctionResult(null, null); } + +/** + * Gets resolved build setting for CONFIGURATION_BUILD_DIR and waits until its + * value matches the `--expected-configuration-build-dir` argument. Waits up to + * 2 minutes. + * + * @param {!WorkspaceDocument} targetWorkspace A `WorkspaceDocument` (Xcode Mac + * Scripting class). + * @param {!CommandArguments} args + * @returns {!FunctionResult} Always returns null as the `result`. + */ +function waitForConfigurationBuildDirToUpdate(targetWorkspace, args) { + // Get the project + let project; + try { + project = targetWorkspace.projects().find(x => x.name() == args.projectName); + } catch (e) { + return new FunctionResult(null, `Failed to find project ${args.projectName}: ${e}`); + } + if (project == null) { + return new FunctionResult(null, `Failed to find project ${args.projectName}.`); + } + + // Get the target + let target; + try { + // The target is probably named the same as the project, but if not, just use the first. + const targets = project.targets(); + target = targets.find(x => x.name() == args.projectName); + if (target == null && targets.length > 0) { + target = targets[0]; + if (args.verbose) { + console.log(`Failed to find target named ${args.projectName}, picking first target: ${target.name()}.`); + } + } + } catch (e) { + return new FunctionResult(null, `Failed to find target: ${e}`); + } + if (target == null) { + return new FunctionResult(null, `Failed to find target.`); + } + + try { + // Use the first build configuration (Debug). Any should do since they all + // include Generated.xcconfig. + const buildConfig = target.buildConfigurations()[0]; + const buildSettings = buildConfig.resolvedBuildSettings().reverse(); + + // CONFIGURATION_BUILD_DIR is often at (reverse) index 225 for Xcode + // projects, so check there first. If it's not there, search the build + // settings (which can be a little slow). + const defaultIndex = 225; + let configurationBuildDirSettings; + if (buildSettings[defaultIndex] != null && buildSettings[defaultIndex].name() === 'CONFIGURATION_BUILD_DIR') { + configurationBuildDirSettings = buildSettings[defaultIndex]; + } else { + configurationBuildDirSettings = buildSettings.find(x => x.name() === 'CONFIGURATION_BUILD_DIR'); + } + + if (configurationBuildDirSettings == null) { + // This should not happen, even if it's not set by Flutter, there should + // always be a resolved build setting for CONFIGURATION_BUILD_DIR. + return new FunctionResult(null, `Unable to find CONFIGURATION_BUILD_DIR.`); + } + + // Wait up to 2 minutes for the CONFIGURATION_BUILD_DIR to update to the + // expected value. + const checkFrequencyInSeconds = 0.5; + const maxWaitInSeconds = 2 * 60; // 2 minutes + const verboseLogInterval = 10 * (1 / checkFrequencyInSeconds); + const iterations = maxWaitInSeconds * (1 / checkFrequencyInSeconds); + for (let i = 0; i < iterations; i++) { + const verbose = args.verbose && i % verboseLogInterval === 0; + + const configurationBuildDir = configurationBuildDirSettings.value(); + if (configurationBuildDir === args.expectedConfigurationBuildDir) { + console.log(`CONFIGURATION_BUILD_DIR: ${configurationBuildDir}`); + return new FunctionResult(null, null); + } + if (verbose) { + console.log(`Current CONFIGURATION_BUILD_DIR: ${configurationBuildDir} while expecting ${args.expectedConfigurationBuildDir}`); + } + delay(checkFrequencyInSeconds); + } + return new FunctionResult(null, 'Timed out waiting for CONFIGURATION_BUILD_DIR to update.'); + } catch (e) { + return new FunctionResult(null, `Failed to get CONFIGURATION_BUILD_DIR: ${e}`); + } +} diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index 0316576fb6e53..ca6e594885f8a 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -721,6 +721,18 @@ class IOSDevice extends Device { return LaunchResult.failed(); } finally { startAppStatus.stop(); + + if ((isCoreDevice || forceXcodeDebugWorkflow) && debuggingOptions.debuggingEnabled && package is BuildableIOSApp) { + // When debugging via Xcode, after the app launches, reset the Generated + // settings to not include the custom configuration build directory. + // This is to prevent confusion if the project is later ran via Xcode + // rather than the Flutter CLI. + await updateGeneratedXcodeProperties( + project: FlutterProject.current(), + buildInfo: debuggingOptions.buildInfo, + targetOverride: mainPath, + ); + } } } @@ -818,6 +830,8 @@ class IOSDevice extends Device { scheme: scheme, xcodeProject: project.xcodeProject, xcodeWorkspace: project.xcodeWorkspace!, + hostAppProjectName: project.hostAppProjectName, + expectedConfigurationBuildDir: bundle.parent.absolute.path, verboseLogging: _logger.isVerbose, ); } else { @@ -839,18 +853,6 @@ class IOSDevice extends Device { shutdownHooks.addShutdownHook(() => _xcodeDebug.exit(force: true)); } - if (package is BuildableIOSApp) { - // After automating Xcode, reset the Generated settings to not include - // the custom configuration build directory. This is to prevent - // confusion if the project is later ran via Xcode rather than the - // Flutter CLI. - await updateGeneratedXcodeProperties( - project: flutterProject, - buildInfo: debuggingOptions.buildInfo, - targetOverride: mainPath, - ); - } - return debugSuccess; } } diff --git a/packages/flutter_tools/lib/src/ios/xcode_debug.dart b/packages/flutter_tools/lib/src/ios/xcode_debug.dart index e1b503643573c..563ec9d8e3444 100644 --- a/packages/flutter_tools/lib/src/ios/xcode_debug.dart +++ b/packages/flutter_tools/lib/src/ios/xcode_debug.dart @@ -85,6 +85,13 @@ class XcodeDebug { project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, + '--project-name', + project.hostAppProjectName, + if (project.expectedConfigurationBuildDir != null) + ...[ + '--expected-configuration-build-dir', + project.expectedConfigurationBuildDir!, + ], '--device-id', deviceId, '--scheme', @@ -310,6 +317,7 @@ class XcodeDebug { _xcode.xcodeAppPath, '-g', // Do not bring the application to the foreground. '-j', // Launches the app hidden. + '-F', // Open "fresh", without restoring windows. xcodeWorkspace.path ], throwOnError: true, @@ -396,6 +404,7 @@ class XcodeDebug { return XcodeDebugProject( scheme: 'Runner', + hostAppProjectName: 'Runner', xcodeProject: tempXcodeProject.childDirectory('Runner.xcodeproj'), xcodeWorkspace: tempXcodeProject.childDirectory('Runner.xcworkspace'), isTemporaryProject: true, @@ -470,6 +479,8 @@ class XcodeDebugProject { required this.scheme, required this.xcodeWorkspace, required this.xcodeProject, + required this.hostAppProjectName, + this.expectedConfigurationBuildDir, this.isTemporaryProject = false, this.verboseLogging = false, }); @@ -477,6 +488,8 @@ class XcodeDebugProject { final String scheme; final Directory xcodeWorkspace; final Directory xcodeProject; + final String hostAppProjectName; + final String? expectedConfigurationBuildDir; final bool isTemporaryProject; /// When [verboseLogging] is true, the xcode_debug.js script will log diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart index 00d19d1edeab3..68d78e5f9b346 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart @@ -472,6 +472,7 @@ void main() { scheme: 'Runner', xcodeWorkspace: fileSystem.directory('/ios/Runner.xcworkspace'), xcodeProject: fileSystem.directory('/ios/Runner.xcodeproj'), + hostAppProjectName: 'Runner', ), expectedDeviceId: '123', expectedLaunchArguments: ['--enable-dart-profiling'], @@ -534,6 +535,8 @@ void main() { scheme: 'Runner', xcodeWorkspace: fileSystem.directory('/ios/Runner.xcworkspace'), xcodeProject: fileSystem.directory('/ios/Runner.xcodeproj'), + hostAppProjectName: 'Runner', + expectedConfigurationBuildDir: '/build/ios/iphoneos', ), expectedDeviceId: '123', expectedLaunchArguments: ['--enable-dart-profiling'], diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart index 8e8e2235a6bcf..9ca0aceca475e 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart @@ -625,6 +625,7 @@ void main() { scheme: 'Runner', xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'), xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'), + hostAppProjectName: 'Runner', ), expectedDeviceId: '123', expectedLaunchArguments: ['--enable-dart-profiling'], @@ -669,6 +670,7 @@ void main() { scheme: 'Runner', xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'), xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'), + hostAppProjectName: 'Runner', ), expectedDeviceId: '123', expectedLaunchArguments: ['--enable-dart-profiling'], @@ -729,6 +731,7 @@ void main() { scheme: 'Runner', xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'), xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'), + hostAppProjectName: 'Runner', ), expectedDeviceId: '123', expectedLaunchArguments: ['--enable-dart-profiling'], @@ -781,6 +784,7 @@ void main() { scheme: 'Runner', xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'), xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'), + hostAppProjectName: 'Runner', ), expectedDeviceId: '123', expectedLaunchArguments: ['--enable-dart-profiling'], diff --git a/packages/flutter_tools/test/general.shard/ios/xcode_debug_test.dart b/packages/flutter_tools/test/general.shard/ios/xcode_debug_test.dart index cbd2416c2d9c2..0fe3a8aa9bdae 100644 --- a/packages/flutter_tools/test/general.shard/ios/xcode_debug_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/xcode_debug_test.dart @@ -56,10 +56,11 @@ void main() { scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, + hostAppProjectName: 'Runner', ); }); - testWithoutContext('succeeds in opening and debugging with launch options and verbose logging', () async { + testWithoutContext('succeeds in opening and debugging with launch options, expectedConfigurationBuildDir, and verbose logging', () async { fakeProcessManager.addCommands([ FakeCommand( command: [ @@ -88,6 +89,7 @@ void main() { pathToXcodeApp, '-g', '-j', + '-F', xcworkspace.path ], ), @@ -105,6 +107,10 @@ void main() { project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, + '--project-name', + project.hostAppProjectName, + '--expected-configuration-build-dir', + '/build/ios/iphoneos', '--device-id', deviceId, '--scheme', @@ -131,6 +137,8 @@ void main() { scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, + hostAppProjectName: 'Runner', + expectedConfigurationBuildDir: '/build/ios/iphoneos', verboseLogging: true, ); @@ -150,7 +158,7 @@ void main() { expect(status, true); }); - testWithoutContext('succeeds in opening and debugging without launch options and verbose logging', () async { + testWithoutContext('succeeds in opening and debugging without launch options, expectedConfigurationBuildDir, and verbose logging', () async { fakeProcessManager.addCommands([ FakeCommand( command: [ @@ -178,6 +186,7 @@ void main() { pathToXcodeApp, '-g', '-j', + '-F', xcworkspace.path ], ), @@ -195,6 +204,8 @@ void main() { project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, + '--project-name', + project.hostAppProjectName, '--device-id', deviceId, '--scheme', @@ -257,6 +268,7 @@ void main() { pathToXcodeApp, '-g', '-j', + '-F', xcworkspace.path ], exception: ProcessException( @@ -266,6 +278,7 @@ void main() { '/non_existant_path', '-g', '-j', + '-F', xcworkspace.path, ], 'The application /non_existant_path cannot be opened for an unexpected reason', @@ -332,6 +345,8 @@ void main() { project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, + '--project-name', + project.hostAppProjectName, '--device-id', deviceId, '--scheme', @@ -401,6 +416,8 @@ void main() { project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, + '--project-name', + project.hostAppProjectName, '--device-id', deviceId, '--scheme', @@ -474,6 +491,8 @@ void main() { project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, + '--project-name', + project.hostAppProjectName, '--device-id', deviceId, '--scheme', @@ -547,6 +566,8 @@ void main() { project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, + '--project-name', + project.hostAppProjectName, '--device-id', deviceId, '--scheme', @@ -674,6 +695,7 @@ void main() { scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, + hostAppProjectName: 'Runner', ); final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, @@ -731,6 +753,7 @@ void main() { scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, + hostAppProjectName: 'Runner', isTemporaryProject: true, ); @@ -794,6 +817,7 @@ void main() { scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, + hostAppProjectName: 'Runner', isTemporaryProject: true, ); final XcodeDebug xcodeDebug = XcodeDebug( @@ -857,6 +881,7 @@ void main() { scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, + hostAppProjectName: 'Runner', ); final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, @@ -899,6 +924,7 @@ void main() { scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, + hostAppProjectName: 'Runner', isTemporaryProject: true, ); final XcodeDebug xcodeDebug = XcodeDebug( @@ -950,6 +976,7 @@ void main() { scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, + hostAppProjectName: 'Runner', ); }); From 6c4930c4ac86fb286f30e31d0ec8bffbcbb9953e Mon Sep 17 00:00:00 2001 From: Kevin Chisholm Date: Wed, 18 Oct 2023 10:57:55 -0500 Subject: [PATCH 36/58] [flutter_releases] Flutter stable 3.13.8 Framework Cherrypicks (#136815) # Flutter stable 3.13.8 Framework ## Scheduled Cherrypicks - https://github.com/flutter/flutter/issues/136388 - https://github.com/flutter/flutter/issues/136680 - https://github.com/flutter/flutter/issues/136743 --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 7efc15d95f56f..0ecd004ff2022 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -a794cf2681c6c9fe7b260e0e84de96298dc9c18b +767d8c75e898091b925519803830fc2721658d07 From d211f42860350d914a5ad8102f9ec32764dc6d06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20S=20Guerrero?= Date: Wed, 25 Oct 2023 13:42:25 -0700 Subject: [PATCH 37/58] [flutter_releases] Flutter stable 3.13.9 Framework Cherrypicks (#137284) # Flutter stable 3.13.9 Framework ## Scheduled Cherrypicks --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 0ecd004ff2022..d33f2f029b7b8 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -767d8c75e898091b925519803830fc2721658d07 +0545f8705df301877d787107bac1a6e9fc9ee1ad From 41466d55a9e94e730bcb26b6b0d338502314a0a3 Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Thu, 4 May 2023 17:02:51 -0500 Subject: [PATCH 38/58] feat(flutter_tools): upgrade flutter.gradle to update shorebird.yaml (#6) --- .../gradle/src/main/groovy/flutter.groovy | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/flutter_tools/gradle/src/main/groovy/flutter.groovy b/packages/flutter_tools/gradle/src/main/groovy/flutter.groovy index 798c19e4cb739..31eefbeab31ff 100644 --- a/packages/flutter_tools/gradle/src/main/groovy/flutter.groovy +++ b/packages/flutter_tools/gradle/src/main/groovy/flutter.groovy @@ -31,6 +31,7 @@ import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.bundling.Jar import org.gradle.internal.os.OperatingSystem import org.gradle.util.VersionNumber +import org.yaml.snakeyaml.Yaml /** * For apps only. Provides the flutter extension used in app/build.gradle. @@ -84,6 +85,7 @@ buildscript { // * AGP version constants in packages/flutter_tools/lib/src/android/gradle_utils.dart // * AGP version in dependencies block in packages/flutter_tools/gradle/build.gradle.kts classpath 'com.android.tools.build:gradle:7.3.0' + classpath group: 'org.yaml', name: 'snakeyaml', version: '2.0' } } @@ -1115,6 +1117,26 @@ class FlutterPlugin implements Plugin { return } Task copyFlutterAssetsTask = addFlutterDeps(variant) + copyFlutterAssetsTask.doLast { + if (variant.flavorName != null && !variant.flavorName.isEmpty()) { + def outputDir = copyFlutterAssetsTask.destinationDir + def shorebirdYamlFile = new File("${outputDir}/flutter_assets/shorebird.yaml") + def flavor = variant.flavorName + def shorebirdYaml = new Yaml().load(shorebirdYamlFile.text) + def flavorAppId = shorebirdYaml['flavors'][flavor] + if (flavorAppId == null) { + throw new GradleException("Cannot find app_id for ${flavor} in shorebird.yaml") + } + def content = 'app_id: ' + flavorAppId + '\n'; + if (shorebirdYaml.containsKey('base_url')) { + content += 'base_url: ' + shorebirdYaml['base_url'] + '\n'; + } + if (shorebirdYaml.containsKey('auto_update')) { + content += 'auto_update: ' + shorebirdYaml['auto_update'] + '\n'; + } + shorebirdYamlFile.write(content) + } + } def variantOutput = variant.outputs.first() def processResources = variantOutput.hasProperty("processResourcesProvider") ? variantOutput.processResourcesProvider.get() : variantOutput.processResources From 3ec5bcbfa7a04d6f1db2fd2bb10012f15e51c303 Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Tue, 1 Aug 2023 11:46:54 -0500 Subject: [PATCH 39/58] feat(flutter_tool): update `mac.dart` to generate shorebird config (#15) --- packages/flutter_tools/lib/src/ios/mac.dart | 55 +++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart index ad69abd211c4c..9984fa4e3c822 100644 --- a/packages/flutter_tools/lib/src/ios/mac.dart +++ b/packages/flutter_tools/lib/src/ios/mac.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'package:meta/meta.dart'; import 'package:process/process.dart'; +import 'package:yaml/yaml.dart'; import '../artifacts.dart'; import '../base/file_system.dart'; @@ -496,6 +497,14 @@ Future buildXcodeProject({ globals.printError('Archive succeeded but the expected xcarchive at $outputDir not found'); } } + + try { + updateShorebirdYaml(buildInfo, app.archiveBundleOutputPath); + } on Exception catch (error) { + globals.printError('[shorebird] failed to generate shorebird configuration.\n$error'); + return XcodeBuildResult(success: false); + } + return XcodeBuildResult( success: true, output: outputDir, @@ -510,6 +519,52 @@ Future buildXcodeProject({ } } +void updateShorebirdYaml(BuildInfo buildInfo, String xcarchivePath) { + final File shorebirdYaml = globals.fs.file( + globals.fs.path.join( + xcarchivePath, + 'Products', + 'Applications', + 'Runner.app', + 'Frameworks', + 'App.framework', + 'flutter_assets', + 'shorebird.yaml', + ), + ); + if (!shorebirdYaml.existsSync()) { + throw Exception('shorebird.yaml not found.'); + } + final YamlDocument yaml = loadYamlDocument(shorebirdYaml.readAsStringSync()); + final YamlMap yamlMap = yaml.contents as YamlMap; + final String? flavor = buildInfo.flavor; + String appId = ''; + if (flavor == null) { + final String? defaultAppId = yamlMap['app_id'] as String?; + if (defaultAppId == null || defaultAppId.isEmpty) { + throw Exception('Cannot find "app_id" in shorebird.yaml'); + } + appId = defaultAppId; + } else { + final YamlMap? yamlFlavors = yamlMap['flavors'] as YamlMap?; + if (yamlFlavors == null) { + throw Exception('Cannot find "flavors" in shorebird.yaml.'); + } + final String? flavorAppId = yamlFlavors[flavor] as String?; + if (flavorAppId == null || flavorAppId.isEmpty) { + throw Exception('Cannot find "app_id" for $flavor in shorebird.yaml'); + } + appId = flavorAppId; + } + final StringBuffer yamlContent = StringBuffer(); + final String? baseUrl = yamlMap['base_url'] as String?; + yamlContent.writeln('app_id: $appId'); + if (baseUrl != null) { + yamlContent.writeln('base_url: $baseUrl'); + } + shorebirdYaml.writeAsStringSync(yamlContent.toString(), flush: true); +} + /// Extended attributes applied by Finder can cause code signing errors. Remove them. /// https://developer.apple.com/library/archive/qa/qa1940/_index.html Future removeFinderExtendedAttributes(FileSystemEntity projectDirectory, ProcessUtils processUtils, Logger logger) async { From 488fa06ecae3b2b0f999e0b366ae5dd4e5830549 Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Fri, 4 Aug 2023 15:14:43 -0500 Subject: [PATCH 40/58] fix(flutter_tools): improve `shorebird.yaml` detection on iOS (#18) --- packages/flutter_tools/lib/src/ios/mac.dart | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart index 9984fa4e3c822..082cad4d1e49c 100644 --- a/packages/flutter_tools/lib/src/ios/mac.dart +++ b/packages/flutter_tools/lib/src/ios/mac.dart @@ -499,7 +499,7 @@ Future buildXcodeProject({ } try { - updateShorebirdYaml(buildInfo, app.archiveBundleOutputPath); + updateShorebirdYaml(buildInfo, app); } on Exception catch (error) { globals.printError('[shorebird] failed to generate shorebird configuration.\n$error'); return XcodeBuildResult(success: false); @@ -519,13 +519,13 @@ Future buildXcodeProject({ } } -void updateShorebirdYaml(BuildInfo buildInfo, String xcarchivePath) { +void updateShorebirdYaml(BuildInfo buildInfo, BuildableIOSApp app) { final File shorebirdYaml = globals.fs.file( globals.fs.path.join( - xcarchivePath, + app.archiveBundleOutputPath, 'Products', 'Applications', - 'Runner.app', + app.name ?? 'Runner.app', 'Frameworks', 'App.framework', 'flutter_assets', @@ -533,7 +533,10 @@ void updateShorebirdYaml(BuildInfo buildInfo, String xcarchivePath) { ), ); if (!shorebirdYaml.existsSync()) { - throw Exception('shorebird.yaml not found.'); + throw Exception(''' +Cannot find shorebird.yaml in ${shorebirdYaml.absolute.path}. +Please file an issue at: https://github.com/shorebirdtech/shorebird/issues/new +'''); } final YamlDocument yaml = loadYamlDocument(shorebirdYaml.readAsStringSync()); final YamlMap yamlMap = yaml.contents as YamlMap; From 1f58b0c7339b9c6dc6c3781a03b66b1225b1ad24 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Wed, 23 Aug 2023 16:43:24 -0400 Subject: [PATCH 41/58] fix snakeyaml import (#21) --- packages/flutter_tools/gradle/build.gradle.kts | 1 + packages/flutter_tools/gradle/src/main/groovy/flutter.groovy | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flutter_tools/gradle/build.gradle.kts b/packages/flutter_tools/gradle/build.gradle.kts index 289693f9a478b..517a725f8cb94 100644 --- a/packages/flutter_tools/gradle/build.gradle.kts +++ b/packages/flutter_tools/gradle/build.gradle.kts @@ -31,4 +31,5 @@ dependencies { // * AGP version constants in packages/flutter_tools/lib/src/android/gradle_utils.dart // * AGP version in buildscript block in packages/flutter_tools/gradle/src/main/flutter.groovy compileOnly("com.android.tools.build:gradle:7.3.0") + implementation("org.yaml:snakeyaml:2.0") } diff --git a/packages/flutter_tools/gradle/src/main/groovy/flutter.groovy b/packages/flutter_tools/gradle/src/main/groovy/flutter.groovy index 31eefbeab31ff..1ae192e58b12c 100644 --- a/packages/flutter_tools/gradle/src/main/groovy/flutter.groovy +++ b/packages/flutter_tools/gradle/src/main/groovy/flutter.groovy @@ -85,7 +85,6 @@ buildscript { // * AGP version constants in packages/flutter_tools/lib/src/android/gradle_utils.dart // * AGP version in dependencies block in packages/flutter_tools/gradle/build.gradle.kts classpath 'com.android.tools.build:gradle:7.3.0' - classpath group: 'org.yaml', name: 'snakeyaml', version: '2.0' } } From ec56c0a0412c7f05b333db4a054e7b17aae126fd Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Thu, 24 Aug 2023 09:32:55 -0500 Subject: [PATCH 42/58] Revert "fix snakeyaml import (#21)" This reverts commit 7b63f1bac9879c2b00f02bc8d404ffc4c7f24ca2. --- packages/flutter_tools/gradle/build.gradle.kts | 1 - packages/flutter_tools/gradle/src/main/groovy/flutter.groovy | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flutter_tools/gradle/build.gradle.kts b/packages/flutter_tools/gradle/build.gradle.kts index 517a725f8cb94..289693f9a478b 100644 --- a/packages/flutter_tools/gradle/build.gradle.kts +++ b/packages/flutter_tools/gradle/build.gradle.kts @@ -31,5 +31,4 @@ dependencies { // * AGP version constants in packages/flutter_tools/lib/src/android/gradle_utils.dart // * AGP version in buildscript block in packages/flutter_tools/gradle/src/main/flutter.groovy compileOnly("com.android.tools.build:gradle:7.3.0") - implementation("org.yaml:snakeyaml:2.0") } diff --git a/packages/flutter_tools/gradle/src/main/groovy/flutter.groovy b/packages/flutter_tools/gradle/src/main/groovy/flutter.groovy index 1ae192e58b12c..31eefbeab31ff 100644 --- a/packages/flutter_tools/gradle/src/main/groovy/flutter.groovy +++ b/packages/flutter_tools/gradle/src/main/groovy/flutter.groovy @@ -85,6 +85,7 @@ buildscript { // * AGP version constants in packages/flutter_tools/lib/src/android/gradle_utils.dart // * AGP version in dependencies block in packages/flutter_tools/gradle/build.gradle.kts classpath 'com.android.tools.build:gradle:7.3.0' + classpath group: 'org.yaml', name: 'snakeyaml', version: '2.0' } } From da938ace2384425c0f9efdf0c43e8865e4fe7f26 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Thu, 24 Aug 2023 11:14:57 -0400 Subject: [PATCH 43/58] reintroduce other snakeyaml dep (#23) --- packages/flutter_tools/gradle/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/flutter_tools/gradle/build.gradle.kts b/packages/flutter_tools/gradle/build.gradle.kts index 289693f9a478b..517a725f8cb94 100644 --- a/packages/flutter_tools/gradle/build.gradle.kts +++ b/packages/flutter_tools/gradle/build.gradle.kts @@ -31,4 +31,5 @@ dependencies { // * AGP version constants in packages/flutter_tools/lib/src/android/gradle_utils.dart // * AGP version in buildscript block in packages/flutter_tools/gradle/src/main/flutter.groovy compileOnly("com.android.tools.build:gradle:7.3.0") + implementation("org.yaml:snakeyaml:2.0") } From b6ce8841a994c03805b112cb1139121fc2520526 Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Tue, 29 Aug 2023 18:01:27 -0500 Subject: [PATCH 44/58] fix(flutter_tools): proxy `auto_update` in `shorebird.yaml` on iOS (#24) --- packages/flutter_tools/lib/src/ios/mac.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart index 082cad4d1e49c..541c89443a558 100644 --- a/packages/flutter_tools/lib/src/ios/mac.dart +++ b/packages/flutter_tools/lib/src/ios/mac.dart @@ -565,6 +565,10 @@ Please file an issue at: https://github.com/shorebirdtech/shorebird/issues/new if (baseUrl != null) { yamlContent.writeln('base_url: $baseUrl'); } + final bool? autoUpdate = yamlMap['auto_update'] as bool?; + if (autoUpdate != null) { + yamlContent.writeln('auto_update: $autoUpdate'); + } shorebirdYaml.writeAsStringSync(yamlContent.toString(), flush: true); } From e744c831b8355bcb9f3b541d42431d9145eea677 Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Thu, 26 Oct 2023 16:38:09 -0500 Subject: [PATCH 45/58] Update engine for 3.13.9 --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index d33f2f029b7b8..e52f4f2daf794 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -0545f8705df301877d787107bac1a6e9fc9ee1ad +ffec33467b8f3803913e355b4c3951b5554279af From fbc4718f8027475b0c3a90ea391149f8a3ca606c Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Tue, 31 Oct 2023 15:10:48 -0500 Subject: [PATCH 46/58] ci: run framework tests (#29) --- .github/workflows/ci.yml | 40 ++++++++++++++++++++++ dev/bots/test.dart | 74 +++++++++++++++++++++------------------- 2 files changed, 78 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000000..b204fadf0463f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: ci + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + runs-on: ${{ matrix.os }} + + name: ๐Ÿงช Test + + env: + FLUTTER_STORAGE_BASE_URL: https://download.shorebird.dev + + steps: + - name: ๐Ÿ“š Git Checkout + uses: actions/checkout@v4 + + - name: ๐ŸŽฏ Setup Dart + uses: dart-lang/setup-dart@v1 + + - name: ๐Ÿ“ฆ Install Dependencies + run: | + dart pub get -C ./dev/bots + dart pub get -C ./dev/tools + + - name: ๐Ÿงช Run Tests + run: dart ./dev/bots/test.dart diff --git a/dev/bots/test.dart b/dev/bots/test.dart index c994041f7839e..1f3e0a0590147 100644 --- a/dev/bots/test.dart +++ b/dev/bots/test.dart @@ -185,7 +185,7 @@ const Map> kWebTestFileKnownFailures = _kAllBuildModes = ['debug', 'profile', 'release']; +const List _kAllBuildModes = ['release']; // The seed used to shuffle tests. If not passed with // --test-randomize-ordering-seed= on the command line, it will be set the @@ -241,24 +241,24 @@ Future main(List args) async { printProgress('Running task: ${Platform.environment[CIRRUS_TASK_NAME]}'); } await selectShard({ - 'add_to_app_life_cycle_tests': _runAddToAppLifeCycleTests, - 'build_tests': _runBuildTests, - 'framework_coverage': _runFrameworkCoverage, + // 'add_to_app_life_cycle_tests': _runAddToAppLifeCycleTests, + // 'build_tests': _runBuildTests, + // 'framework_coverage': _runFrameworkCoverage, 'framework_tests': _runFrameworkTests, - 'tool_tests': _runToolTests, - // web_tool_tests is also used by HHH: https://dart.googlesource.com/recipes/+/refs/heads/master/recipes/dart/flutter_engine.py - 'web_tool_tests': _runWebToolTests, - 'tool_integration_tests': _runIntegrationToolTests, - 'tool_host_cross_arch_tests': _runToolHostCrossArchTests, - // All the unit/widget tests run using `flutter test --platform=chrome --web-renderer=html` - 'web_tests': _runWebHtmlUnitTests, - // All the unit/widget tests run using `flutter test --platform=chrome --web-renderer=canvaskit` - 'web_canvaskit_tests': _runWebCanvasKitUnitTests, - // All web integration tests - 'web_long_running_tests': _runWebLongRunningTests, - 'flutter_plugins': _runFlutterPackagesTests, - 'skp_generator': _runSkpGeneratorTests, - kTestHarnessShardName: _runTestHarnessTests, // Used for testing this script; also run as part of SHARD=framework_tests, SUBSHARD=misc. + // 'tool_tests': _runToolTests, + // // web_tool_tests is also used by HHH: https://dart.googlesource.com/recipes/+/refs/heads/master/recipes/dart/flutter_engine.py + // 'web_tool_tests': _runWebToolTests, + // 'tool_integration_tests': _runIntegrationToolTests, + // 'tool_host_cross_arch_tests': _runToolHostCrossArchTests, + // // All the unit/widget tests run using `flutter test --platform=chrome --web-renderer=html` + // 'web_tests': _runWebHtmlUnitTests, + // // All the unit/widget tests run using `flutter test --platform=chrome --web-renderer=canvaskit` + // 'web_canvaskit_tests': _runWebCanvasKitUnitTests, + // // All web integration tests + // 'web_long_running_tests': _runWebLongRunningTests, + // 'flutter_plugins': _runFlutterPackagesTests, + // 'skp_generator': _runSkpGeneratorTests, + // kTestHarnessShardName: _runTestHarnessTests, // Used for testing this script; also run as part of SHARD=framework_tests, SUBSHARD=misc. }); } catch (error, stackTrace) { foundError([ @@ -319,7 +319,8 @@ Future _validateEngineHash() async { Future _runTestHarnessTests() async { printProgress('${green}Running test harness tests...$reset'); - await _validateEngineHash(); + // TODO(felangel): flutter_test executable does not point to the shorebird engine revision. + // await _validateEngineHash(); // Verify that the tests actually return failure on failure and success on // success. @@ -399,10 +400,11 @@ Future _runTestHarnessTests() async { } // Verify that we correctly generated the version file. - final String? versionError = await verifyVersion(File(path.join(flutterRoot, 'version'))); - if (versionError != null) { - foundError([versionError]); - } + // TODO(felangel): teach shorebird to generate the correct version file + // final String? versionError = await verifyVersion(File(path.join(flutterRoot, 'version'))); + // if (versionError != null) { + // foundError([versionError]); + // } } final String _toolsPath = path.join(flutterRoot, 'packages', 'flutter_tools'); @@ -978,10 +980,10 @@ Future _runFrameworkTests() async { Future runMisc() async { printProgress('${green}Running package tests$reset for directories other than packages/flutter'); await _runTestHarnessTests(); - await runExampleTests(); - await _runDartTest(path.join(flutterRoot, 'dev', 'bots')); - await _runDartTest(path.join(flutterRoot, 'dev', 'devicelab'), ensurePrecompiledTool: false); // See https://github.com/flutter/flutter/issues/86209 - await _runDartTest(path.join(flutterRoot, 'dev', 'conductor', 'core'), forceSingleCore: true); + // await runExampleTests(); + // await _runDartTest(path.join(flutterRoot, 'dev', 'bots')); + // await _runDartTest(path.join(flutterRoot, 'dev', 'devicelab'), ensurePrecompiledTool: false); // See https://github.com/flutter/flutter/issues/86209 + // await _runDartTest(path.join(flutterRoot, 'dev', 'conductor', 'core'), forceSingleCore: true); // TODO(gspencergoog): Remove the exception for fatalWarnings once https://github.com/flutter/flutter/issues/113782 has landed. await _runFlutterTest(path.join(flutterRoot, 'dev', 'integration_tests', 'android_semantics_testing'), fatalWarnings: false); await _runFlutterTest(path.join(flutterRoot, 'dev', 'integration_tests', 'ui')); @@ -991,12 +993,12 @@ Future _runFrameworkTests() async { await _runFlutterTest(path.join(flutterRoot, 'dev', 'tools', 'gen_keycodes')); await _runFlutterTest(path.join(flutterRoot, 'dev', 'benchmarks', 'test_apps', 'stocks')); await _runFlutterTest(path.join(flutterRoot, 'packages', 'flutter_driver'), tests: [path.join('test', 'src', 'real_tests')]); - await _runFlutterTest(path.join(flutterRoot, 'packages', 'integration_test'), options: [ - '--enable-vmservice', - // Web-specific tests depend on Chromium, so they run as part of the web_long_running_tests shard. - '--exclude-tags=web', - ]); - await _runFlutterTest(path.join(flutterRoot, 'packages', 'flutter_goldens')); + // await _runFlutterTest(path.join(flutterRoot, 'packages', 'integration_test'), options: [ + // '--enable-vmservice', + // // Web-specific tests depend on Chromium, so they run as part of the web_long_running_tests shard. + // '--exclude-tags=web', + // ]); + // await _runFlutterTest(path.join(flutterRoot, 'packages', 'flutter_goldens')); await _runFlutterTest(path.join(flutterRoot, 'packages', 'flutter_localizations')); await _runFlutterTest(path.join(flutterRoot, 'packages', 'flutter_test')); await _runFlutterTest(path.join(flutterRoot, 'packages', 'fuchsia_remote_debug_protocol')); @@ -1028,9 +1030,9 @@ Future _runFrameworkTests() async { } await selectSubshard({ - 'widgets': runWidgets, - 'libraries': runLibraries, - 'slow': runSlow, + // 'widgets': runWidgets, + // 'libraries': runLibraries, + // 'slow': runSlow, 'misc': runMisc, }); } From 5f4aef6e506f24e85711f820b7c6af3e3959c258 Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Thu, 9 Nov 2023 15:48:54 -0600 Subject: [PATCH 47/58] chore: roll flutter engine (#31) --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index e52f4f2daf794..354e60cd50630 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -ffec33467b8f3803913e355b4c3951b5554279af +7cf74ca79c3a332497b31afe28cbec935e897a85 From 39df2792f537b1fc62a9c668a6990f585bd91456 Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Fri, 10 Nov 2023 08:13:40 -0800 Subject: [PATCH 48/58] chore: roll engine version --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 354e60cd50630..8e2367c22f5f8 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -7cf74ca79c3a332497b31afe28cbec935e897a85 +e81fa131e59506d9f6af2a0cee7de749131f1bf0 From 447487a4d2f1a73376e82c61e708f75e315cdaa5 Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Fri, 8 Dec 2023 14:15:35 -0600 Subject: [PATCH 49/58] chore: roll engine to `a902c22268` (#32) --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 8e2367c22f5f8..58d37039e811e 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -e81fa131e59506d9f6af2a0cee7de749131f1bf0 +a902c222680d63925b55c47cff7b3c6cab3326b7 From a3d5f7c614aa1cc4d6cb1506e74fd1c81678e68e Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Sun, 10 Dec 2023 21:18:39 -0800 Subject: [PATCH 50/58] chore: roll engine --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 58d37039e811e..94b85f4a34321 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -a902c222680d63925b55c47cff7b3c6cab3326b7 +0757a9001dc3dcfce6f09fdd443904827274d22e From b27620fa7dca89c742c12b1277571f7a0d6a9740 Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Tue, 12 Dec 2023 14:03:37 -0600 Subject: [PATCH 51/58] chore: bump engine to 6994442c95 (#35) `6994442c955e50bc6511dfd424763d7b601d68a0` --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 77be70ab6a124..53dc09d637dc6 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -3c2ea337f67ff735d8b47a891c044ba038cb829e \ No newline at end of file +6994442c955e50bc6511dfd424763d7b601d68a0 \ No newline at end of file From b9b23902966504a9778f4c07e3a3487fa84dcb2a Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Tue, 12 Dec 2023 16:40:04 -0600 Subject: [PATCH 52/58] chore: bump engine to ca9c251787 --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 53dc09d637dc6..274fe29da91ca 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -6994442c955e50bc6511dfd424763d7b601d68a0 \ No newline at end of file +ca9c251787d1951487d1637589eea1c1d77d4109 \ No newline at end of file From 86b0e651691298dd922967ab39f98bec0b348ec5 Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Thu, 14 Dec 2023 08:22:16 -0600 Subject: [PATCH 53/58] chore: roll engine to a3b5975e06 a3b5975e0618f7b810a3b6e5d521494f4fcdcb71 --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 274fe29da91ca..fa5533e38492d 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -ca9c251787d1951487d1637589eea1c1d77d4109 \ No newline at end of file +a3b5975e0618f7b810a3b6e5d521494f4fcdcb71 \ No newline at end of file From 1a6115bebe31e63508c312d14e69e973e1a59dbf Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Wed, 20 Dec 2023 11:49:14 -0500 Subject: [PATCH 54/58] Merge flutter 3.16.4 into shorebird/dev (#36) * [CP] Gold fix for stable branch (#139764) Fixes https://github.com/flutter/flutter/issues/139673 Cherry picks https://github.com/flutter/flutter/pull/139706 to the stable branch to fix the tree. * [macOS] Suppress Xcode 15 createItemModels warning (#138243) (#139782) As of Xcode 15 on macOS Sonoma, the following message is (repeatedly) output to stderr during builds (repros on non-Flutter apps). It is supppressed in xcode itself, but not when run from the command-line. ``` 2023-11-10 10:44:58.031 xcodebuild[61115:1017566] [MT] DVTAssertions: Warning in /System/Volumes/Data/SWE/Apps/DT/BuildRoots/BuildRoot11/ActiveBuildRoot/Library/Caches/com.apple.xbs/Sources/IDEFrameworks/IDEFrameworks-22267/IDEFoundation/Provisioning/Capabilities Infrastructure/IDECapabilityQuerySelection.swift:103 Details: createItemModels creation requirements should not create capability item model for a capability item model that already exists. Function: createItemModels(for:itemModelSource:) Thread: <_NSMainThread: 0x6000027c0280>{number = 1, name = main} Please file a bug at https://feedbackassistant.apple.com with this warning message and any useful information you can provide. ``` This suppresses this message from stderr in our macOS build logs. Issue: https://github.com/flutter/flutter/issues/135277 Cherry-pick: https://github.com/flutter/flutter/issues/139284 https://github.com/flutter/flutter/wiki/Tree-hygiene#overview [Tree Hygiene]: https://github.com/flutter/flutter/wiki/Tree-hygiene [test-exempt]: https://github.com/flutter/flutter/wiki/Tree-hygiene#tests [Flutter Style Guide]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo [Features we expect every widget to implement]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ https://github.com/flutter/flutter/wiki/Tree-hygiene#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/wiki/Chat *Replace this paragraph with a description of what this PR is changing or adding, and why. Consider including before/after screenshots.* *List which issues are fixed by this PR. You must list at least one issue. An issue is not required if the PR fixes something trivial like a typo.* *If you had to change anything in the [flutter/tests] repo, include a link to the migration guide as per the [breaking change policy].* * [CP] have `Java.version` return null if `java --version` fails or cannot be run (#139698) cherry-picks changes from https://github.com/flutter/flutter/pull/139614 onto the stable channel * [CP] Catch error for missing directory in `FontConfigManager` (#138496) (#139743) Closes: - https://github.com/flutter/flutter/issues/138434 We will catch any errors while attempting to clear the temp directories that don't exist for the `FontConfigManager` class --------- Co-authored-by: Kate Lovett Co-authored-by: Chris Bracken Co-authored-by: Andrew Kolos Co-authored-by: Elias Yishak <42216813+eliasyishak@users.noreply.github.com> --- .../flutter_goldens/lib/flutter_goldens.dart | 15 +- .../test/flutter_goldens_test.dart | 139 +++++++++++++++++- .../flutter_tools/lib/src/android/java.dart | 5 + .../lib/src/macos/build_macos.dart | 20 ++- .../lib/src/test/font_config_manager.dart | 6 +- .../hermetic/build_macos_test.dart | 9 ++ .../test/general.shard/android/java_test.dart | 19 ++- .../flutter_tester_device_test.dart | 12 ++ 8 files changed, 215 insertions(+), 10 deletions(-) diff --git a/packages/flutter_goldens/lib/flutter_goldens.dart b/packages/flutter_goldens/lib/flutter_goldens.dart index d9b901e267027..5e4a26b411f60 100644 --- a/packages/flutter_goldens/lib/flutter_goldens.dart +++ b/packages/flutter_goldens/lib/flutter_goldens.dart @@ -19,6 +19,7 @@ export 'package:flutter_goldens_client/skia_client.dart'; // https://github.com/flutter/flutter/wiki/Writing-a-golden-file-test-for-package%3Aflutter const String _kFlutterRootKey = 'FLUTTER_ROOT'; +final RegExp _kMainBranch = RegExp(r'master|main'); /// Main method that can be used in a `flutter_test_config.dart` file to set /// [goldenFileComparator] to an instance of [FlutterGoldenFileComparator] that @@ -259,7 +260,9 @@ class FlutterPostSubmitFileComparator extends FlutterGoldenFileComparator { final bool luciPostSubmit = platform.environment.containsKey('SWARMING_TASK_ID') && platform.environment.containsKey('GOLDCTL') // Luci tryjob environments contain this value to inform the [FlutterPreSubmitComparator]. - && !platform.environment.containsKey('GOLD_TRYJOB'); + && !platform.environment.containsKey('GOLD_TRYJOB') + // Only run on main branch. + && _kMainBranch.hasMatch(platform.environment['GIT_BRANCH'] ?? ''); return luciPostSubmit; } @@ -346,7 +349,9 @@ class FlutterPreSubmitFileComparator extends FlutterGoldenFileComparator { static bool isAvailableForEnvironment(Platform platform) { final bool luciPreSubmit = platform.environment.containsKey('SWARMING_TASK_ID') && platform.environment.containsKey('GOLDCTL') - && platform.environment.containsKey('GOLD_TRYJOB'); + && platform.environment.containsKey('GOLD_TRYJOB') + // Only run on the main branch + && _kMainBranch.hasMatch(platform.environment['GIT_BRANCH'] ?? ''); return luciPreSubmit; } } @@ -413,9 +418,11 @@ class FlutterSkippingFileComparator extends FlutterGoldenFileComparator { /// If we are in a CI environment, LUCI or Cirrus, but are not using the other /// comparators, we skip. static bool isAvailableForEnvironment(Platform platform) { - return platform.environment.containsKey('SWARMING_TASK_ID') + return (platform.environment.containsKey('SWARMING_TASK_ID') // Some builds are still being run on Cirrus, we should skip these. - || platform.environment.containsKey('CIRRUS_CI'); + || platform.environment.containsKey('CIRRUS_CI')) + // If we are in CI, skip on branches that are not main. + && !_kMainBranch.hasMatch(platform.environment['GIT_BRANCH'] ?? ''); } } diff --git a/packages/flutter_goldens/test/flutter_goldens_test.dart b/packages/flutter_goldens/test/flutter_goldens_test.dart index e64198f0bc486..3e2c05ea9cfc1 100644 --- a/packages/flutter_goldens/test/flutter_goldens_test.dart +++ b/packages/flutter_goldens/test/flutter_goldens_test.dart @@ -716,6 +716,55 @@ void main() { 'FLUTTER_ROOT': _kFlutterRoot, 'SWARMING_TASK_ID' : '12345678990', 'GOLDCTL' : 'goldctl', + 'GIT_BRANCH' : 'master', + }, + operatingSystem: 'macos', + ); + expect( + FlutterPostSubmitFileComparator.isAvailableForEnvironment(platform), + isTrue, + ); + }); + + test('returns false on release branches in postsubmit', () { + platform = FakePlatform( + environment: { + 'FLUTTER_ROOT': _kFlutterRoot, + 'SWARMING_TASK_ID' : 'sweet task ID', + 'GOLDCTL' : 'some/path', + 'GIT_BRANCH' : 'flutter-3.16-candidate.0', + }, + operatingSystem: 'macos', + ); + expect( + FlutterPostSubmitFileComparator.isAvailableForEnvironment(platform), + isFalse, + ); + }); + + test('returns true on master branch in postsubmit', () { + platform = FakePlatform( + environment: { + 'FLUTTER_ROOT': _kFlutterRoot, + 'SWARMING_TASK_ID' : 'sweet task ID', + 'GOLDCTL' : 'some/path', + 'GIT_BRANCH' : 'master', + }, + operatingSystem: 'macos', + ); + expect( + FlutterPostSubmitFileComparator.isAvailableForEnvironment(platform), + isTrue, + ); + }); + + test('returns true on main branch in postsubmit', () { + platform = FakePlatform( + environment: { + 'FLUTTER_ROOT': _kFlutterRoot, + 'SWARMING_TASK_ID' : 'sweet task ID', + 'GOLDCTL' : 'some/path', + 'GIT_BRANCH' : 'main', }, operatingSystem: 'macos', ); @@ -828,6 +877,57 @@ void main() { }); group('correctly determines testing environment', () { + test('returns false on release branches in presubmit', () { + platform = FakePlatform( + environment: { + 'FLUTTER_ROOT': _kFlutterRoot, + 'SWARMING_TASK_ID' : 'sweet task ID', + 'GOLDCTL' : 'some/path', + 'GOLD_TRYJOB' : 'true', + 'GIT_BRANCH' : 'flutter-3.16-candidate.0', + }, + operatingSystem: 'macos', + ); + expect( + FlutterPreSubmitFileComparator.isAvailableForEnvironment(platform), + isFalse, + ); + }); + + test('returns true on master branch in presubmit', () { + platform = FakePlatform( + environment: { + 'FLUTTER_ROOT': _kFlutterRoot, + 'SWARMING_TASK_ID' : 'sweet task ID', + 'GOLDCTL' : 'some/path', + 'GOLD_TRYJOB' : 'true', + 'GIT_BRANCH' : 'master', + }, + operatingSystem: 'macos', + ); + expect( + FlutterPreSubmitFileComparator.isAvailableForEnvironment(platform), + isTrue, + ); + }); + + test('returns true on main branch in presubmit', () { + platform = FakePlatform( + environment: { + 'FLUTTER_ROOT': _kFlutterRoot, + 'SWARMING_TASK_ID' : 'sweet task ID', + 'GOLDCTL' : 'some/path', + 'GOLD_TRYJOB' : 'true', + 'GIT_BRANCH' : 'main', + }, + operatingSystem: 'macos', + ); + expect( + FlutterPreSubmitFileComparator.isAvailableForEnvironment(platform), + isTrue, + ); + }); + test('returns true for Luci', () { platform = FakePlatform( environment: { @@ -835,6 +935,7 @@ void main() { 'SWARMING_TASK_ID' : '12345678990', 'GOLDCTL' : 'goldctl', 'GOLD_TRYJOB' : 'git/ref/12345/head', + 'GIT_BRANCH' : 'master', }, operatingSystem: 'macos', ); @@ -908,6 +1009,39 @@ void main() { group('Skipping', () { group('correctly determines testing environment', () { + test('returns true on release branches in presubmit', () { + platform = FakePlatform( + environment: { + 'FLUTTER_ROOT': _kFlutterRoot, + 'SWARMING_TASK_ID' : 'sweet task ID', + 'GOLDCTL' : 'some/path', + 'GOLD_TRYJOB' : 'true', + 'GIT_BRANCH' : 'flutter-3.16-candidate.0', + }, + operatingSystem: 'macos', + ); + expect( + FlutterSkippingFileComparator.isAvailableForEnvironment(platform), + isTrue, + ); + }); + + test('returns true on release branches in postsubmit', () { + platform = FakePlatform( + environment: { + 'FLUTTER_ROOT': _kFlutterRoot, + 'SWARMING_TASK_ID' : 'sweet task ID', + 'GOLDCTL' : 'some/path', + 'GIT_BRANCH' : 'flutter-3.16-candidate.0', + }, + operatingSystem: 'macos', + ); + expect( + FlutterSkippingFileComparator.isAvailableForEnvironment(platform), + isTrue, + ); + }); + test('returns true on Cirrus builds', () { platform = FakePlatform( environment: { @@ -936,7 +1070,7 @@ void main() { ); }); - test('returns false - no CI', () { + test('returns false - not in CI', () { platform = FakePlatform( environment: { 'FLUTTER_ROOT': _kFlutterRoot, @@ -944,8 +1078,7 @@ void main() { operatingSystem: 'macos', ); expect( - FlutterSkippingFileComparator.isAvailableForEnvironment( - platform), + FlutterSkippingFileComparator.isAvailableForEnvironment(platform), isFalse, ); }); diff --git a/packages/flutter_tools/lib/src/android/java.dart b/packages/flutter_tools/lib/src/android/java.dart index 699fcfddfaaf8..f1e2934e365ff 100644 --- a/packages/flutter_tools/lib/src/android/java.dart +++ b/packages/flutter_tools/lib/src/android/java.dart @@ -136,6 +136,10 @@ class Java { /// Returns the version of java in the format \d(.\d)+(.\d)+ /// Returns null if version could not be determined. late final Version? version = (() { + if (!canRun()) { + return null; + } + final RunResult result = _processUtils.runSync( [binaryPath, '--version'], environment: environment, @@ -143,6 +147,7 @@ class Java { if (result.exitCode != 0) { _logger.printTrace('java --version failed: exitCode: ${result.exitCode}' ' stdout: ${result.stdout} stderr: ${result.stderr}'); + return null; } final String rawVersionOutput = result.stdout; final List versionLines = rawVersionOutput.split('\n'); diff --git a/packages/flutter_tools/lib/src/macos/build_macos.dart b/packages/flutter_tools/lib/src/macos/build_macos.dart index 174261af99061..626a9c0668327 100644 --- a/packages/flutter_tools/lib/src/macos/build_macos.dart +++ b/packages/flutter_tools/lib/src/macos/build_macos.dart @@ -27,9 +27,27 @@ import 'migrations/remove_macos_framework_link_and_embedding_migration.dart'; /// Filter out xcodebuild logging unrelated to macOS builds: /// ``` /// xcodebuild[2096:1927385] Requested but did not find extension point with identifier Xcode.IDEKit.ExtensionPointIdentifierToBundleIdentifier for extension Xcode.DebuggerFoundation.AppExtensionToBundleIdentifierMap.watchOS of plug-in com.apple.dt.IDEWatchSupportCore +/// /// note: Using new build system +/// +/// xcodebuild[61115:1017566] [MT] DVTAssertions: Warning in /System/Volumes/Data/SWE/Apps/DT/BuildRoots/BuildRoot11/ActiveBuildRoot/Library/Caches/com.apple.xbs/Sources/IDEFrameworks/IDEFrameworks-22267/IDEFoundation/Provisioning/Capabilities Infrastructure/IDECapabilityQuerySelection.swift:103 +/// Details: createItemModels creation requirements should not create capability item model for a capability item model that already exists. +/// Function: createItemModels(for:itemModelSource:) +/// Thread: <_NSMainThread: 0x6000027c0280>{number = 1, name = main} +/// Please file a bug at https://feedbackassistant.apple.com with this warning message and any useful information you can provide. + /// ``` -final RegExp _filteredOutput = RegExp(r'^((?!Requested but did not find extension point with identifier|note\:).)*$'); +final RegExp _filteredOutput = RegExp( + r'^((?!' + r'Requested but did not find extension point with identifier|' + r'note\:|' + r'\[MT\] DVTAssertions: Warning in /System/Volumes/Data/SWE/|' + r'Details\: createItemModels|' + r'Function\: createItemModels|' + r'Thread\: <_NSMainThread\:|' + r'Please file a bug at https\://feedbackassistant\.apple\.' + r').)*$' + ); /// Builds the macOS project through xcodebuild. // TODO(zanderso): refactor to share code with the existing iOS code. diff --git a/packages/flutter_tools/lib/src/test/font_config_manager.dart b/packages/flutter_tools/lib/src/test/font_config_manager.dart index 92d641503590f..052de458a3627 100644 --- a/packages/flutter_tools/lib/src/test/font_config_manager.dart +++ b/packages/flutter_tools/lib/src/test/font_config_manager.dart @@ -34,7 +34,11 @@ class FontConfigManager { Future dispose() async { if (_fontsDirectory != null) { globals.printTrace('Deleting ${_fontsDirectory!.path}...'); - await _fontsDirectory!.delete(recursive: true); + try { + await _fontsDirectory!.delete(recursive: true); + } on FileSystemException { + // Silently exit + } _fontsDirectory = null; } } diff --git a/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart index 014ae63b6e07a..3ba9dc7429c29 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart @@ -124,6 +124,11 @@ note: Building targets in dependency order stderr: ''' 2022-03-24 10:07:21.954 xcodebuild[2096:1927385] Requested but did not find extension point with identifier Xcode.IDEKit.ExtensionSentinelHostApplications for extension Xcode.DebuggerFoundation.AppExtensionHosts.watchOS of plug-in com.apple.dt.IDEWatchSupportCore 2022-03-24 10:07:21.954 xcodebuild[2096:1927385] Requested but did not find extension point with identifier Xcode.IDEKit.ExtensionPointIdentifierToBundleIdentifier for extension Xcode.DebuggerFoundation.AppExtensionToBundleIdentifierMap.watchOS of plug-in com.apple.dt.IDEWatchSupportCore +2023-11-10 10:44:58.030 xcodebuild[61115:1017566] [MT] DVTAssertions: Warning in /System/Volumes/Data/SWE/Apps/DT/BuildRoots/BuildRoot11/ActiveBuildRoot/Library/Caches/com.apple.xbs/Sources/IDEFrameworks/IDEFrameworks-22267/IDEFoundation/Provisioning/Capabilities Infrastructure/IDECapabilityQuerySelection.swift:103 +Details: createItemModels creation requirements should not create capability item model for a capability item model that already exists. +Function: createItemModels(for:itemModelSource:) +Thread: <_NSMainThread: 0x6000027c0280>{number = 1, name = main} +Please file a bug at https://feedbackassistant.apple.com with this warning message and any useful information you can provide. STDERR STUFF ''', onRun: () { @@ -247,6 +252,10 @@ STDERR STUFF expect(testLogger.errorText, isNot(contains('xcodebuild[2096:1927385]'))); expect(testLogger.errorText, isNot(contains('Using new build system'))); expect(testLogger.errorText, isNot(contains('Building targets in dependency order'))); + expect(testLogger.errorText, isNot(contains('DVTAssertions: Warning in'))); + expect(testLogger.errorText, isNot(contains('createItemModels'))); + expect(testLogger.errorText, isNot(contains('_NSMainThread:'))); + expect(testLogger.errorText, isNot(contains('Please file a bug at https://feedbackassistant'))); }, overrides: { FileSystem: () => fileSystem, ProcessManager: () => FakeProcessManager.list([ diff --git a/packages/flutter_tools/test/general.shard/android/java_test.dart b/packages/flutter_tools/test/general.shard/android/java_test.dart index c7ff6786dae02..368f2565c5f1a 100644 --- a/packages/flutter_tools/test/general.shard/android/java_test.dart +++ b/packages/flutter_tools/test/general.shard/android/java_test.dart @@ -183,7 +183,7 @@ OpenJDK 64-Bit Server VM Zulu19.32+15-CA (build 19.0.2+7, mixed mode, sharing) }); }); - group('getVersionString', () { + group('version', () { late Java java; setUp(() { @@ -208,6 +208,23 @@ OpenJDK 64-Bit Server VM Zulu19.32+15-CA (build 19.0.2+7, mixed mode, sharing) ); } + testWithoutContext('is null when java binary cannot be run', () async { + addJavaVersionCommand(''); + processManager.excludedExecutables.add('java'); + + expect(java.version, null); + }); + + testWithoutContext('is null when java --version returns a non-zero exit code', () async { + processManager.addCommand( + FakeCommand( + command: [java.binaryPath, '--version'], + exitCode: 1, + ), + ); + expect(java.version, null); + }); + testWithoutContext('parses jdk 8', () { addJavaVersionCommand(''' java version "1.8.0_202" diff --git a/packages/flutter_tools/test/general.shard/flutter_tester_device_test.dart b/packages/flutter_tools/test/general.shard/flutter_tester_device_test.dart index 752100624f888..d3e035f4e7b3f 100644 --- a/packages/flutter_tools/test/general.shard/flutter_tester_device_test.dart +++ b/packages/flutter_tools/test/general.shard/flutter_tester_device_test.dart @@ -50,6 +50,18 @@ void main() { uriConverter: (String input) => '$input/converted', ); + testUsingContext('Missing dir error caught for FontConfigManger.dispose', () async { + final FontConfigManager fontConfigManager = FontConfigManager(); + + final Directory fontsDirectory = fileSystem.file(fontConfigManager.fontConfigFile).parent; + fontsDirectory.deleteSync(recursive: true); + + await fontConfigManager.dispose(); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + }); + group('The FLUTTER_TEST environment variable is passed to the test process', () { setUp(() { processManager = FakeProcessManager.list([]); From fad9340d3b09ad4d2f52a00ed674a10ca1522ad7 Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Wed, 20 Dec 2023 10:30:42 -0800 Subject: [PATCH 55/58] chore: roll engine to fcdba2b4478d0a43186dda0e1992406a23165262 --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index fa5533e38492d..5b7baae152df9 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -a3b5975e0618f7b810a3b6e5d521494f4fcdcb71 \ No newline at end of file +fcdba2b4478d0a43186dda0e1992406a23165262 From 7e92b034c5dddb727cf5e802c23cddd39b325a7f Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Wed, 20 Dec 2023 12:56:21 -0800 Subject: [PATCH 56/58] chore: roll engine to 9472e50354253fde3f2cdb25d8115f7aa6d3c68a --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 5b7baae152df9..aeddc986935d1 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -fcdba2b4478d0a43186dda0e1992406a23165262 +9472e50354253fde3f2cdb25d8115f7aa6d3c68a From 4e8a7c746ae6f10951f3e676f10b82b21d7300a5 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Tue, 2 Jan 2024 17:16:44 -0500 Subject: [PATCH 57/58] chore: update to Flutter 3.16.5 (#37) * chore: update to Flutter 3.16.5 * use correct engine rev --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index aeddc986935d1..75c5946e512a8 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -9472e50354253fde3f2cdb25d8115f7aa6d3c68a +001b60083b642c134c62e7c10bc34f188b0a2869 From 699fd9f5c9bec4b8658804669c3933ca336210a8 Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Tue, 16 Jan 2024 08:01:08 -0800 Subject: [PATCH 58/58] chore: roll engine to 49dec30114f043371698bc12d119d2abd72f475a --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 75c5946e512a8..6ab6e3bf7ea1c 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -001b60083b642c134c62e7c10bc34f188b0a2869 +49dec30114f043371698bc12d119d2abd72f475a