[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
diff --git a/.ci.yaml b/.ci.yaml
index cd162a0..9116008 100644
--- a/.ci.yaml
+++ b/.ci.yaml
@@ -3664,6 +3664,16 @@
["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 @@
["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 49f579e..81fdb38 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 0000000..a17c45d
--- /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<void> 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: <String, String>{
+ '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 0000000..f51b231
--- /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<void> 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: <String, String>{
+ 'FORCE_XCODE_DEBUG': 'true',
+ },
+ ));
+}
diff --git a/dev/devicelab/lib/tasks/integration_tests.dart b/dev/devicelab/lib/tasks/integration_tests.dart
index 0172d57..c06e717 100644
--- a/dev/devicelab/lib/tasks/integration_tests.dart
+++ b/dev/devicelab/lib/tasks/integration_tests.dart
@@ -106,10 +106,11 @@
).call;
}
-TaskFunction createEndToEndDriverTest() {
+TaskFunction createEndToEndDriverTest({Map<String, String>? environment}) {
return DriverTest(
'${flutterDirectory.path}/dev/integration_tests/ui',
'lib/driver.dart',
+ environment: environment,
).call;
}
@@ -173,6 +174,7 @@
this.testTarget, {
this.extraOptions = const <String>[],
this.deviceIdOverride,
+ this.environment,
}
);
@@ -180,6 +182,7 @@
final String testTarget;
final List<String> extraOptions;
final String? deviceIdOverride;
+ final Map<String, String>? environment;
Future<TaskResult> call() {
return inDirectory<TaskResult>(testDirectory, () async {
@@ -202,7 +205,7 @@
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 b35bb41..193e4e5 100644
--- a/dev/devicelab/lib/tasks/perf_tests.dart
+++ b/dev/devicelab/lib/tasks/perf_tests.dart
@@ -232,10 +232,11 @@
).run;
}
-TaskFunction createFlutterGalleryStartupTest({String target = 'lib/main.dart'}) {
+TaskFunction createFlutterGalleryStartupTest({String target = 'lib/main.dart', Map<String, String>? runEnvironment}) {
return StartupTest(
'${flutterDirectory.path}/dev/integration_tests/flutter_gallery',
target: target,
+ runEnvironment: runEnvironment,
).run;
}
@@ -693,11 +694,17 @@
/// 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<String, String>? runEnvironment;
Future<TaskResult> run() async {
return inDirectory<TaskResult>(testDirectory, () async {
@@ -771,18 +778,23 @@
const int maxFailures = 3;
int currentFailures = 0;
for (int i = 0; i < iterations; i += 1) {
- final int result = await flutter('run', options: <String>[
- '--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: <String>[
+ '--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<String, dynamic> 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 0000000..25f16a2
--- /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<string>=} 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<string>} 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<string>} args List of arguments passed from the command line.
+ * @returns {!Object.<string, string>} 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 08cefe5..a5a4f92 100644
--- a/packages/flutter_tools/lib/src/context_runner.dart
+++ b/packages/flutter_tools/lib/src/context_runner.dart
@@ -359,6 +359,7 @@
platform: globals.platform,
fileSystem: globals.fs,
xcodeProjectInterpreter: globals.xcodeProjectInterpreter!,
+ userMessages: globals.userMessages,
),
XCDevice: () => XCDevice(
processManager: globals.processManager,
@@ -375,6 +376,7 @@
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 a919109..091bac0 100644
--- a/packages/flutter_tools/lib/src/device.dart
+++ b/packages/flutter_tools/lib/src/device.dart
@@ -1156,6 +1156,7 @@
Map<String, Object?> platformArgs, {
bool ipv6 = false,
DeviceConnectionInterface interfaceType = DeviceConnectionInterface.attached,
+ bool isCoreDevice = false,
}) {
final String dartVmFlags = computeDartVmFlags(this);
return <String>[
@@ -1169,7 +1170,10 @@
if (environmentType == EnvironmentType.simulator && dartVmFlags.isNotEmpty)
'--dart-flags=$dartVmFlags',
if (useTestFonts) '--use-test-fonts',
- if (debuggingEnabled) ...<String>[
+ // 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) ...<String>[
'--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 0000000..aa39adb
--- /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<List<Object?>> _listCoreDevices({
+ Duration timeout = const Duration(seconds: _minimumTimeoutInSeconds),
+ }) async {
+ if (!_xcode.isDevicectlInstalled) {
+ _logger.printError('devicectl is not installed.');
+ return <Object?>[];
+ }
+
+ // 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<String> command = <String>[
+ ..._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<String, Object?>)['result'];
+ if (decodeResult is Map<String, Object?>) {
+ final Object? decodeDevices = decodeResult['devices'];
+ if (decodeDevices is List<Object?>) {
+ return decodeDevices;
+ }
+ }
+ _logger.printError('devicectl returned unexpected JSON response: $stringOutput');
+ return <Object?>[];
+ } on FormatException {
+ // We failed to parse the devicectl output, or it returned junk.
+ _logger.printError('devicectl returned non-JSON response: $stringOutput');
+ return <Object?>[];
+ }
+ } on ProcessException catch (err) {
+ _logger.printError('Error executing devicectl: $err');
+ return <Object?>[];
+ } finally {
+ tempDirectory.deleteSync(recursive: true);
+ }
+ }
+
+ Future<List<IOSCoreDevice>> getCoreDevices({
+ Duration timeout = const Duration(seconds: _minimumTimeoutInSeconds),
+ }) async {
+ final List<IOSCoreDevice> devices = <IOSCoreDevice>[];
+
+ final List<Object?> devicesSection = await _listCoreDevices(timeout: timeout);
+ for (final Object? deviceObject in devicesSection) {
+ if (deviceObject is Map<String, Object?>) {
+ 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<List<Object?>> _listInstalledApps({
+ required String deviceId,
+ String? bundleId,
+ }) async {
+ if (!_xcode.isDevicectlInstalled) {
+ _logger.printError('devicectl is not installed.');
+ return <Object?>[];
+ }
+
+ final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('core_devices.');
+ final File output = tempDirectory.childFile('core_device_app_list.json');
+ output.createSync();
+
+ final List<String> command = <String>[
+ ..._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<String, Object?>)['result'];
+ if (decodeResult is Map<String, Object?>) {
+ final Object? decodeApps = decodeResult['apps'];
+ if (decodeApps is List<Object?>) {
+ return decodeApps;
+ }
+ }
+ _logger.printError('devicectl returned unexpected JSON response: $stringOutput');
+ return <Object?>[];
+ } on FormatException {
+ // We failed to parse the devicectl output, or it returned junk.
+ _logger.printError('devicectl returned non-JSON response: $stringOutput');
+ return <Object?>[];
+ }
+ } on ProcessException catch (err) {
+ _logger.printError('Error executing devicectl: $err');
+ return <Object?>[];
+ } finally {
+ tempDirectory.deleteSync(recursive: true);
+ }
+ }
+
+ @visibleForTesting
+ Future<List<IOSCoreDeviceInstalledApp>> getInstalledApps({
+ required String deviceId,
+ String? bundleId,
+ }) async {
+ final List<IOSCoreDeviceInstalledApp> apps = <IOSCoreDeviceInstalledApp>[];
+
+ final List<Object?> appsData = await _listInstalledApps(deviceId: deviceId, bundleId: bundleId);
+ for (final Object? appObject in appsData) {
+ if (appObject is Map<String, Object?>) {
+ apps.add(IOSCoreDeviceInstalledApp.fromBetaJson(appObject));
+ }
+ }
+ return apps;
+ }
+
+ Future<bool> isAppInstalled({
+ required String deviceId,
+ required String bundleId,
+ }) async {
+ final List<IOSCoreDeviceInstalledApp> apps = await getInstalledApps(
+ deviceId: deviceId,
+ bundleId: bundleId,
+ );
+ if (apps.isNotEmpty) {
+ return true;
+ }
+ return false;
+ }
+
+ Future<bool> 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<String> command = <String>[
+ ..._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<String, Object?>)['info'];
+ if (decodeResult is Map<String, Object?> && 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<bool> 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<String> command = <String>[
+ ..._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<String, Object?>)['info'];
+ if (decodeResult is Map<String, Object?> && 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<bool> launchApp({
+ required String deviceId,
+ required String bundleId,
+ List<String> launchArguments = const <String>[],
+ }) 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<String> command = <String>[
+ ..._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<String, Object?>)['info'];
+ if (decodeResult is Map<String, Object?> && 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<String, Object?> data, {
+ required Logger logger,
+ }) {
+ final List<_IOSCoreDeviceCapability> capabilitiesList = <_IOSCoreDeviceCapability>[];
+ if (data['capabilities'] is List<Object?>) {
+ final List<Object?> capabilitiesData = data['capabilities']! as List<Object?>;
+ for (final Object? capabilityData in capabilitiesData) {
+ if (capabilityData != null && capabilityData is Map<String, Object?>) {
+ capabilitiesList.add(_IOSCoreDeviceCapability.fromBetaJson(capabilityData));
+ }
+ }
+ }
+
+ _IOSCoreDeviceConnectionProperties? connectionProperties;
+ if (data['connectionProperties'] is Map<String, Object?>) {
+ final Map<String, Object?> connectionPropertiesData = data['connectionProperties']! as Map<String, Object?>;
+ connectionProperties = _IOSCoreDeviceConnectionProperties.fromBetaJson(
+ connectionPropertiesData,
+ logger: logger,
+ );
+ }
+
+ IOSCoreDeviceProperties? deviceProperties;
+ if (data['deviceProperties'] is Map<String, Object?>) {
+ final Map<String, Object?> devicePropertiesData = data['deviceProperties']! as Map<String, Object?>;
+ deviceProperties = IOSCoreDeviceProperties.fromBetaJson(devicePropertiesData);
+ }
+
+ _IOSCoreDeviceHardwareProperties? hardwareProperties;
+ if (data['hardwareProperties'] is Map<String, Object?>) {
+ final Map<String, Object?> hardwarePropertiesData = data['hardwareProperties']! as Map<String, Object?>;
+ 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<String, Object?> 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<String, Object?> data, {
+ required Logger logger,
+ }) {
+ List<String>? localHostnames;
+ if (data['localHostnames'] is List<Object?>) {
+ final List<Object?> values = data['localHostnames']! as List<Object?>;
+ try {
+ localHostnames = List<String>.from(values);
+ } on TypeError {
+ logger.printTrace('Error parsing localHostnames value: $values');
+ }
+ }
+
+ List<String>? potentialHostnames;
+ if (data['potentialHostnames'] is List<Object?>) {
+ final List<Object?> values = data['potentialHostnames']! as List<Object?>;
+ try {
+ potentialHostnames = List<String>.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<String>? localHostnames;
+ final String? pairingState;
+ final List<String>? 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<String, Object?> 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<String, Object?> data, {
+ required Logger logger,
+ }) {
+ _IOSCoreDeviceCPUType? cpuType;
+ if (data['cpuType'] is Map<String, Object?>) {
+ cpuType = _IOSCoreDeviceCPUType.fromBetaJson(data['cpuType']! as Map<String, Object?>);
+ }
+
+ List<_IOSCoreDeviceCPUType>? supportedCPUTypes;
+ if (data['supportedCPUTypes'] is List<Object?>) {
+ final List<Object?> values = data['supportedCPUTypes']! as List<Object?>;
+ final List<_IOSCoreDeviceCPUType> cpuTypes = <_IOSCoreDeviceCPUType>[];
+ for (final Object? cpuTypeData in values) {
+ if (cpuTypeData is Map<String, Object?>) {
+ cpuTypes.add(_IOSCoreDeviceCPUType.fromBetaJson(cpuTypeData));
+ }
+ }
+ supportedCPUTypes = cpuTypes;
+ }
+
+ List<int>? supportedDeviceFamilies;
+ if (data['supportedDeviceFamilies'] is List<Object?>) {
+ final List<Object?> values = data['supportedDeviceFamilies']! as List<Object?>;
+ try {
+ supportedDeviceFamilies = List<int>.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<int>? 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<String, Object?> 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<String, Object?> 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 000529d..85d89ac 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/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 '../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 @@
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 @@
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 @@
@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<IOSApp?, DeviceLogReader> _logReaders = <IOSApp?, DeviceLogReader>{};
DevicePortForwarder? _portForwarder;
@@ -349,10 +364,17 @@
}) 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 @@
int installationResult;
try {
- installationResult = await _iosDeploy.installApp(
- deviceId: id,
- bundlePath: bundle.path,
- appDeltaDirectory: app.appDeltaDirectory,
- launchArguments: <String>[],
- 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: <String>[],
+ interfaceType: connectionInterface,
+ );
+ }
} on ProcessException catch (e) {
_logger.printError(e.message);
return false;
@@ -404,10 +433,17 @@
}) 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 @@
bool ipv6 = false,
String? userIdentifier,
@visibleForTesting Duration? discoveryTimeout,
+ @visibleForTesting ShutdownHooks? shutdownHooks,
}) async {
String? packageId;
if (isWirelesslyConnected &&
@@ -441,6 +478,18 @@
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 @@
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 @@
platformArgs,
ipv6: ipv6,
interfaceType: connectionInterface,
+ isCoreDevice: isCoreDevice,
);
Status startAppStatus = _logger.startProgress(
'Installing and launching...',
@@ -516,7 +567,16 @@
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 @@
_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 @@
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();
- }
-
- // 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...',
+ // 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();
+ }
- // 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,
- );
+ // 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...',
+ );
+ });
- mDNSLookupTimer.cancel();
+ // 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();
+ }
} 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<Uri?> vmUrlFromMDns = MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch(
+ packageId,
+ this,
+ usesIpv6: ipv6,
+ );
+ final Future<Uri?> vmUrlFromLogs = vmServiceDiscovery.uri;
+ localUri = await Future.any(
+ <Future<Uri?>>[vmUrlFromMDns, vmUrlFromLogs]
+ );
+ } else {
+ localUri = await vmServiceDiscovery?.uri;
+ }
}
timer.cancel();
if (localUri == null) {
@@ -613,6 +723,110 @@
}
}
+ /// 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<bool> _startAppOnCoreDevice({
+ required DebuggingOptions debuggingOptions,
+ required IOSApp package,
+ required List<String> 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<bool> stopApp(
ApplicationPackage? app, {
@@ -623,6 +837,9 @@
if (deployDebugger != null && deployDebugger.debuggerAttached) {
return deployDebugger.exit();
}
+ if (_xcodeDebug.debugStarted) {
+ return _xcodeDebug.exit();
+ }
return false;
}
@@ -669,7 +886,14 @@
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<void> takeScreenshot(File outputFile) async {
@@ -757,14 +981,18 @@
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] <Notice>:
// iOS 10 format: Runner(Flutter)[297] <Notice>:
_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 @@
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 @@
bool useSyslog = true,
bool usingCISystem = false,
int? majorSdkVersion,
+ bool isWirelesslyConnected = false,
+ bool isCoreDevice = false,
}) {
final int sdkVersion;
if (majorSdkVersion != null) {
@@ -798,16 +1031,22 @@
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 @@
// 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 @@
}
}
- /// Used to track messages prefixed with "flutter:" when [useBothLogDeviceReaders]
- /// is true.
- final List<String> _streamFlutterMessages = <String>[];
+ /// Used to track messages prefixed with "flutter:" from the fallback log source.
+ final List<String> _fallbackStreamFlutterMessages = <String>[];
- /// 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 @@
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<void> _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<void> _listenToUnifiedLoggingEvents(FlutterVmService connectedVmService) async {
+ if (!useUnifiedLogging) {
return;
}
try {
@@ -909,13 +1251,9 @@
}
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 @@
/// 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 @@
}
// 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 @@
// 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<void>((Process process) {
@@ -982,7 +1308,7 @@
// 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 @@
return (String line) {
if (printing) {
if (!_anyLineRegex.hasMatch(line)) {
- _addToLinesController(decodeSyslog(line), IOSDeviceLogSource.idevicesyslog);
+ addToLinesController(decodeSyslog(line), IOSDeviceLogSource.idevicesyslog);
return;
}
@@ -1019,7 +1345,7 @@
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 @@
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 cbd1f89..ecca27b 100644
--- a/packages/flutter_tools/lib/src/ios/mac.dart
+++ b/packages/flutter_tools/lib/src/ios/mac.dart
@@ -116,6 +116,7 @@
DarwinArch? activeArch,
bool codesign = true,
String? deviceID,
+ bool isCoreDevice = false,
bool configOnly = false,
XcodeBuildAction buildAction = XcodeBuildAction.build,
}) async {
@@ -224,6 +225,7 @@
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 2ef75a2..0e7c42b 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 @@
String? targetOverride,
bool useMacOSConfig = false,
String? buildDirOverride,
+ bool usingCoreDevice = false,
}) async {
final List<String> xcodeBuildSettings = await _xcodeBuildSettingsLines(
project: project,
@@ -42,6 +43,7 @@
targetOverride: targetOverride,
useMacOSConfig: useMacOSConfig,
buildDirOverride: buildDirOverride,
+ usingCoreDevice: usingCoreDevice,
);
_updateGeneratedXcodePropertiesFile(
@@ -143,6 +145,7 @@
String? targetOverride,
bool useMacOSConfig = false,
String? buildDirOverride,
+ bool usingCoreDevice = false,
}) async {
final List<String> xcodeBuildSettings = <String>[];
@@ -170,6 +173,12 @@
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 0000000..e1b5036
--- /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<bool> debugApp({
+ required XcodeDebugProject project,
+ required String deviceId,
+ required List<String> 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<String>? stdoutSubscription;
+ StreamSubscription<String>? stderrSubscription;
+ try {
+ startDebugActionProcess = await _processUtils.start(
+ <String>[
+ ..._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<String>(utf8.decoder)
+ .transform<String>(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<String>(utf8.decoder)
+ .transform<String>(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<bool> 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<void>.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<bool> _forceExitXcode() async {
+ final RunResult result = await _processUtils.run(
+ <String>[
+ 'killall',
+ '-9',
+ 'Xcode',
+ ],
+ );
+
+ if (result.exitCode != 0) {
+ _logger.printError('Error killing Xcode: ${result.exitCode}\n${result.stderr}');
+ return false;
+ }
+ return true;
+ }
+
+ Future<bool> _isProjectOpenInXcode({
+ required XcodeDebugProject project,
+ }) async {
+
+ final RunResult result = await _processUtils.run(
+ <String>[
+ ..._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<String, Object?>) {
+ 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<bool> _openProjectInXcode({
+ required Directory xcodeWorkspace,
+ }) async {
+ try {
+ await _processUtils.run(
+ <String>[
+ '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<bool> stopDebuggingApp({
+ required XcodeDebugProject project,
+ bool closeXcode = false,
+ bool promptToSaveOnClose = false,
+ }) async {
+ final RunResult result = await _processUtils.run(
+ <String>[
+ ..._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<XcodeDebugProject> 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,
+ <String, Object>{
+ '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<String, Object?> data) {
+ XcodeAutomationScriptDebugResult? debugResult;
+ if (data['debugResult'] != null && data['debugResult'] is Map<String, Object?>) {
+ debugResult = XcodeAutomationScriptDebugResult.fromJson(
+ data['debugResult']! as Map<String, Object?>,
+ );
+ }
+ 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<String, Object?> 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 c53fdca..a2c66ee 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: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 '../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 @@
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 @@
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 @@
final IOSDeploy _iosDeploy;
final Xcode _xcode;
final IProxy _iProxy;
+ final IOSCoreDeviceControl _coreDeviceControl;
+ final XcodeDebug _xcodeDebug;
List<Object>? _cachedListResults;
@@ -457,6 +478,17 @@
return const <IOSDevice>[];
}
+ final Map<String, IOSCoreDevice> coreDeviceMap = <String, IOSCoreDevice>{};
+ if (_xcode.isDevicectlInstalled) {
+ final List<IOSCoreDevice> 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 @@
}
}
+ 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 @@
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 4784013..540f08d 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/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 @@
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 @@
XcodeProjectInterpreter? xcodeProjectInterpreter,
Platform? platform,
FileSystem? fileSystem,
+ String? flutterRoot,
Logger? logger,
}) {
platform ??= FakePlatform(
@@ -72,6 +79,8 @@
platform: platform,
processManager: processManager,
fileSystem: fileSystem ?? MemoryFileSystem.test(),
+ userMessages: UserMessages(),
+ flutterRoot: flutterRoot,
logger: logger,
xcodeProjectInterpreter: xcodeProjectInterpreter ?? XcodeProjectInterpreter.test(processManager: processManager),
);
@@ -81,6 +90,8 @@
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 @@
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 @@
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(
+ <String>[...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 8219611..da65275 100644
--- a/packages/flutter_tools/lib/src/mdns_discovery.dart
+++ b/packages/flutter_tools/lib/src/mdns_discovery.dart
@@ -130,9 +130,9 @@
/// 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 @@
/// 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<MDnsVmServiceDiscoveryResult?> 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 @@
MDnsClient client, {
String? applicationId,
int? deviceVmservicePort,
+ String? deviceName,
bool ipv6 = false,
bool useDeviceIPAsHost = false,
Duration timeout = const Duration(minutes: 10),
@@ -178,6 +185,7 @@
client,
applicationId: applicationId,
deviceVmservicePort: deviceVmservicePort,
+ deviceName: deviceName,
ipv6: ipv6,
useDeviceIPAsHost: useDeviceIPAsHost,
timeout: timeout,
@@ -193,6 +201,7 @@
MDnsClient client, {
String? applicationId,
int? deviceVmservicePort,
+ String? deviceName,
bool ipv6 = false,
bool useDeviceIPAsHost = false,
required Duration timeout,
@@ -263,6 +272,11 @@
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 @@
}
}
+ @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<String> matchingRecords =
@@ -354,7 +377,7 @@
/// 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 @@
/// 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<Uri?> 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 d0902d5..3729d89 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 0000000..2b2b697
--- /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 0000000..8f544ef
--- /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 = "<group>";
+ };
+ 97C146EF1CF9000F007C117D /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146EE1CF9000F007C117D /* Runner.app */,
+ );
+ name = Products;
+ sourceTree = "<group>";
+ };
+/* 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 0000000..919434a
--- /dev/null
+++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Workspace
+ version = "1.0">
+ <FileRef
+ location = "self:">
+ </FileRef>
+</Workspace>
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 0000000..18d9810
--- /dev/null
+++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>IDEDidComputeMac32BitWarning</key>
+ <true/>
+</dict>
+</plist>
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 0000000..f9b0d7c
--- /dev/null
+++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>PreviewsEnabled</key>
+ <false/>
+</dict>
+</plist>
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 0000000..bcca935
--- /dev/null
+++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/xcshareddata/xcschemes/Runner.xcscheme.tmpl
@@ -0,0 +1,82 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+ LastUpgradeVersion = "1430"
+ version = "1.3">
+ <BuildAction
+ parallelizeBuildables = "YES"
+ buildImplicitDependencies = "YES">
+ <BuildActionEntries>
+ <BuildActionEntry
+ buildForTesting = "YES"
+ buildForRunning = "YES"
+ buildForProfiling = "YES"
+ buildForArchiving = "YES"
+ buildForAnalyzing = "YES">
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "97C146ED1CF9000F007C117D"
+ BuildableName = "Runner.app"
+ BlueprintName = "Runner"
+ ReferencedContainer = "container:Runner.xcodeproj">
+ </BuildableReference>
+ </BuildActionEntry>
+ </BuildActionEntries>
+ </BuildAction>
+ <TestAction
+ buildConfiguration = "Debug"
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+ shouldUseLaunchSchemeArgsEnv = "YES">
+ </TestAction>
+ <LaunchAction
+ buildConfiguration = "Debug"
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+ launchStyle = "0"
+ useCustomWorkingDirectory = "NO"
+ ignoresPersistentStateOnLaunch = "NO"
+ debugDocumentVersioning = "YES"
+ debugServiceExtension = "internal"
+ allowLocationSimulation = "YES">
+ <PathRunnable
+ runnableDebuggingMode = "0"
+ FilePath = "{{applicationBundlePath}}">
+ </PathRunnable>
+ <MacroExpansion>
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "97C146ED1CF9000F007C117D"
+ BuildableName = "Runner.app"
+ BlueprintName = "Runner"
+ ReferencedContainer = "container:Runner.xcodeproj">
+ </BuildableReference>
+ </MacroExpansion>
+ </LaunchAction>
+ <ProfileAction
+ buildConfiguration = "Profile"
+ shouldUseLaunchSchemeArgsEnv = "YES"
+ savedToolIdentifier = ""
+ useCustomWorkingDirectory = "NO"
+ debugDocumentVersioning = "YES">
+ <PathRunnable
+ runnableDebuggingMode = "0"
+ FilePath = "{{applicationBundlePath}}">
+ </PathRunnable>
+ <MacroExpansion>
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "97C146ED1CF9000F007C117D"
+ BuildableName = "Runner.app"
+ BlueprintName = "Runner"
+ ReferencedContainer = "container:Runner.xcodeproj">
+ </BuildableReference>
+ </MacroExpansion>
+ </ProfileAction>
+ <AnalyzeAction
+ buildConfiguration = "Debug">
+ </AnalyzeAction>
+ <ArchiveAction
+ buildConfiguration = "Release"
+ revealArchiveInOrganizer = "YES">
+ </ArchiveAction>
+</Scheme>
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 0000000..1d526a1
--- /dev/null
+++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Workspace
+ version = "1.0">
+ <FileRef
+ location = "group:Runner.xcodeproj">
+ </FileRef>
+</Workspace>
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 0000000..18d9810
--- /dev/null
+++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>IDEDidComputeMac32BitWarning</key>
+ <true/>
+</dict>
+</plist>
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 0000000..f9b0d7c
--- /dev/null
+++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>PreviewsEnabled</key>
+ <false/>
+</dict>
+</plist>
diff --git a/packages/flutter_tools/test/general.shard/device_test.dart b/packages/flutter_tools/test/general.shard/device_test.dart
index 2774536..860a58f 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 @@
);
});
+ testWithoutContext('Get launch arguments for physical CoreDevice with debugging enabled with no launch arguments', () {
+ final DebuggingOptions original = DebuggingOptions.enabled(
+ BuildInfo.debug,
+ );
+
+ final List<String> launchArguments = original.getIOSLaunchArguments(
+ EnvironmentType.physical,
+ null,
+ <String, Object?>{},
+ isCoreDevice: true,
+ );
+
+ expect(
+ launchArguments.join(' '),
+ <String>[
+ '--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 0000000..d820e1a
--- /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<IOSCoreDevice> 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: <String>[
+ '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: <String>[
+ '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: <String>[
+ '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: <String>[
+ '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: <String>[
+ '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: <String>[
+ '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: <String>[
+ '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: <String>[
+ '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: <String>[
+ '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: <String>[
+ '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: <String>['--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: <String>[
+ '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: <String>[
+ '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: <String>[
+ '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: <String>[
+ 'xcrun',
+ 'devicectl',
+ 'device',
+ 'info',
+ 'apps',
+ '--device',
+ deviceId,
+ '--bundle-id',
+ bundleId,
+ '--json-output',
+ tempFile.path,
+ ],
+ onRun: () {
+ expect(tempFile, exists);
+ tempFile.writeAsStringSync(deviceControlOutput);
+ },
+ ));
+
+ final List<IOSCoreDeviceInstalledApp> 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: <String>[
+ '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: <String>[
+ '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: <String>[
+ '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: <String>[
+ '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: <String>[
+ '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: <String>[
+ 'xcrun',
+ 'devicectl',
+ 'list',
+ 'devices',
+ '--timeout',
+ '5',
+ '--json-output',
+ tempFile.path,
+ ],
+ onRun: () {
+ expect(tempFile, exists);
+ tempFile.writeAsStringSync(deviceControlOutput);
+ },
+ ));
+
+ final List<IOSCoreDevice> 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: <String>[
+ 'xcrun',
+ 'devicectl',
+ 'list',
+ 'devices',
+ '--timeout',
+ '5',
+ '--json-output',
+ tempFile.path,
+ ],
+ onRun: () {
+ expect(tempFile, exists);
+ tempFile.writeAsStringSync(deviceControlOutput);
+ },
+ ));
+
+ final List<IOSCoreDevice> 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: <String>[
+ 'xcrun',
+ 'devicectl',
+ 'list',
+ 'devices',
+ '--timeout',
+ '5',
+ '--json-output',
+ tempFile.path,
+ ],
+ onRun: () {
+ expect(tempFile, exists);
+ tempFile.writeAsStringSync(deviceControlOutput);
+ },
+ ));
+
+ final List<IOSCoreDevice> 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: <String>[
+ 'xcrun',
+ 'devicectl',
+ 'list',
+ 'devices',
+ '--timeout',
+ '5',
+ '--json-output',
+ tempFile.path,
+ ],
+ onRun: () {
+ expect(tempFile, exists);
+ tempFile.writeAsStringSync(deviceControlOutput);
+ },
+ ));
+
+ final List<IOSCoreDevice> 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: <String>[
+ 'xcrun',
+ 'devicectl',
+ 'list',
+ 'devices',
+ '--timeout',
+ '5',
+ '--json-output',
+ tempFile.path,
+ ],
+ onRun: () {
+ expect(tempFile, exists);
+ tempFile.writeAsStringSync(deviceControlOutput);
+ },
+ ));
+
+ final List<IOSCoreDevice> 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,
+ <String>[
+ '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, <String>[
+ '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: <String>[
+ 'xcrun',
+ 'devicectl',
+ 'list',
+ 'devices',
+ '--timeout',
+ '5',
+ '--json-output',
+ tempFile.path,
+ ],
+ onRun: () {
+ expect(tempFile, exists);
+ tempFile.writeAsStringSync(deviceControlOutput);
+ },
+ ));
+
+ final List<IOSCoreDevice> 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: <String>[
+ 'xcrun',
+ 'devicectl',
+ 'list',
+ 'devices',
+ '--timeout',
+ '5',
+ '--json-output',
+ tempFile.path,
+ ],
+ onRun: () {
+ expect(tempFile, exists);
+ tempFile.writeAsStringSync(deviceControlOutput);
+ },
+ ));
+
+ final List<IOSCoreDevice> 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, <int>[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: <String>[
+ 'xcrun',
+ 'devicectl',
+ 'list',
+ 'devices',
+ '--timeout',
+ '5',
+ '--json-output',
+ tempFile.path,
+ ],
+ onRun: () {
+ expect(tempFile, exists);
+ tempFile.writeAsStringSync(deviceControlOutput);
+ },
+ ));
+
+ final List<IOSCoreDevice> 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: <String>[
+ 'xcrun',
+ 'devicectl',
+ 'list',
+ 'devices',
+ '--timeout',
+ '5',
+ '--json-output',
+ tempFile.path,
+ ],
+ onRun: () {
+ expect(tempFile, exists);
+ tempFile.writeAsStringSync(deviceControlOutput);
+ },
+ ));
+
+ final List<IOSCoreDevice> 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: <String>[
+ 'xcrun',
+ 'devicectl',
+ 'list',
+ 'devices',
+ '--timeout',
+ '5',
+ '--json-output',
+ tempFile.path,
+ ],
+ onRun: () {
+ expect(tempFile, exists);
+ tempFile.writeAsStringSync(deviceControlOutput);
+ },
+ ));
+
+ final List<IOSCoreDevice> 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 cb222d9..146e05b 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/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 @@
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 @@
logger: logger,
processManager: FakeProcessManager.any(),
);
+ coreDeviceControl = FakeIOSCoreDeviceControl();
+ xcodeDebug = FakeXcodeDebug();
});
testWithoutContext('successfully instantiates on Mac OS', () {
@@ -72,12 +78,15 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
processManager: fakeProcessManager,
logger: logger,
);
+ coreDeviceControl = FakeIOSCoreDeviceControl();
+ xcodeDebug = FakeXcodeDebug();
device1 = IOSDevice(
'd83d5bc53967baa0ee18626ba87b6254b2ab5418',
@@ -503,12 +564,15 @@
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 @@
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 @@
late IOSDeploy iosDeploy;
late IMobileDevice iMobileDevice;
late IOSWorkflow iosWorkflow;
+ late IOSCoreDeviceControl coreDeviceControl;
+ late XcodeDebug xcodeDebug;
late IOSDevice notConnected1;
setUp(() {
@@ -803,6 +872,8 @@
processManager: fakeProcessManager,
logger: logger,
);
+ coreDeviceControl = FakeIOSCoreDeviceControl();
+ xcodeDebug = FakeXcodeDebug();
notConnected1 = IOSDevice(
'00000001-0000000000000000',
name: 'iPad',
@@ -811,12 +882,15 @@
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 @@
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 8c93d8f..6a5f522 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/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 @@
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 @@
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 @@
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 @@
expect(wasAppUninstalled, false);
});
+
+
}
IOSDevice setUpIOSDevice({
@@ -322,6 +393,7 @@
Logger? logger,
DeviceConnectionInterface? interfaceType,
Artifacts? artifacts,
+ bool isCoreDevice = false,
}) {
logger ??= BufferLogger.test();
final FakePlatform platform = FakePlatform(
@@ -357,9 +429,42 @@
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<bool> installApp({
+ required String deviceId,
+ required String bundlePath,
+ }) async {
+
+ return true;
+ }
+
+ @override
+ Future<bool> uninstallApp({
+ required String deviceId,
+ required String bundleId,
+ }) async {
+
+ return true;
+ }
+
+ @override
+ Future<bool> 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 9f65a67..01506cd 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 @@
]));
});
- 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 @@
iosDeployDebugger.debuggerAttached = true;
final Stream<String> debuggingLogs = Stream<String>.fromIterable(<String>[
- 'Message from debugger',
+ 'flutter: Message from debugger',
]);
iosDeployDebugger.logLines = debuggingLogs;
logReader.debuggerStream = iosDeployDebugger;
// Wait for stream listeners to fire.
await expectLater(logReader.logLines, emitsInAnyOrder(<Matcher>[
- equals('Message from debugger'),
+ equals('flutter: Message from debugger'),
]));
});
});
@@ -349,9 +349,8 @@
});
});
- 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 @@
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 @@
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 @@
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: <String>[
- ideviceSyslogPath, '-u', '1234',
- ],
- stdout: '''
-Runner(Flutter)[297] <Notice>: A is for ari
-Runner(Flutter)[297] <Notice>: I is for ichigo
-May 30 13:56:28 Runner(Flutter)[2037] <Notice>: flutter: The Dart VM service is listening on http://127.0.0.1:63098/35ZezGIQLnw=/
-May 30 13:56:28 Runner(Flutter)[2037] <Notice>: flutter: This is a test
-May 30 13:56:28 Runner(Flutter)[2037] <Notice>: [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,17 @@
cache: fakeCache,
logger: logger,
),
- usingCISystem: true,
- majorSdkVersion: 16,
+ majorSdkVersion: 13,
);
- final List<String> lines = await logReader.logLines.toList();
- expect(logReader.useBothLogDeviceReaders, isTrue);
- expect(processManager, hasNoRemainingExpectations);
- expect(lines, <String>[
- '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: <String>[
- ideviceSyslogPath, '-u', '1234',
- ],
- stdout: '''
-May 30 13:56:28 Runner(Flutter)[2037] <Notice>: flutter: The Dart VM service is listening on http://127.0.0.1:63098/35ZezGIQLnw=/
-May 30 13:56:28 Runner(Flutter)[2037] <Notice>: flutter: Check for duplicate
-May 30 13:56:28 Runner(Flutter)[2037] <Notice>: [VERBOSE-2:FlutterDarwinContextMetalImpeller.mm(39)] Using the Impeller rendering backend.
-'''
- ),
- );
-
- final Stream<String> debuggingLogs = Stream<String>.fromIterable(<String>[
- '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.',
- ]);
-
+ testWithoutContext('for iOS 13 or greater non-CoreDevice, _iosDeployDebugger not attached, and VM is connected', () {
final IOSDeviceLogReader logReader = IOSDeviceLogReader.test(
iMobileDevice: IMobileDevice(
artifacts: artifacts,
@@ -456,32 +433,67 @@
cache: fakeCache,
logger: logger,
),
- usingCISystem: true,
- majorSdkVersion: 16,
+ majorSdkVersion: 13,
);
+
+ final FlutterVmService vmService = FakeVmServiceHost(requests: <VmServiceExpectation>[
+ const FakeVmServiceRequest(method: 'streamListen', args: <String, Object>{
+ 'streamId': 'Debug',
+ }),
+ const FakeVmServiceRequest(method: 'streamListen', args: <String, Object>{
+ 'streamId': 'Stdout',
+ }),
+ const FakeVmServiceRequest(method: 'streamListen', args: <String, Object>{
+ '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,
+ processManager: processManager,
+ cache: fakeCache,
+ logger: logger,
+ ),
+ majorSdkVersion: 13,
+ );
+
final FakeIOSDeployDebugger iosDeployDebugger = FakeIOSDeployDebugger();
- iosDeployDebugger.logLines = debuggingLogs;
+ iosDeployDebugger.debuggerAttached = true;
logReader.debuggerStream = iosDeployDebugger;
- final Future<List<String>> logLines = logReader.logLines.toList();
- final List<String> lines = await logLines;
- expect(logReader.useBothLogDeviceReaders, isTrue);
- expect(processManager, hasNoRemainingExpectations);
- expect(lines.length, 3);
- expect(lines, containsAll(<String>[
- '(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: <VmServiceExpectation>[
+ const FakeVmServiceRequest(method: 'streamListen', args: <String, Object>{
+ 'streamId': 'Debug',
+ }),
+ const FakeVmServiceRequest(method: 'streamListen', args: <String, Object>{
+ 'streamId': 'Stdout',
+ }),
+ const FakeVmServiceRequest(method: 'streamListen', args: <String, Object>{
+ 'streamId': 'Stderr',
+ }),
+ ]).vmService;
+ 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('IOSDeviceLogReader only uses ios-deploy debugger when useBothLogDeviceReaders is false', () async {
- final Stream<String> debuggingLogs = Stream<String>.fromIterable(<String>[
- '(lldb) 2023-05-30 13:48:52.461894-0500 Runner[2019:1101495] [VERBOSE-2:FlutterDarwinContextMetalImpeller.mm(39)] Using the Impeller rendering backend.',
- '',
- ]);
-
+ testWithoutContext('for iOS 16 or greater non-CoreDevice', () {
final IOSDeviceLogReader logReader = IOSDeviceLogReader.test(
iMobileDevice: IMobileDevice(
artifacts: artifacts,
@@ -491,20 +503,490 @@
),
majorSdkVersion: 16,
);
- final FakeIOSDeployDebugger iosDeployDebugger = FakeIOSDeployDebugger();
- iosDeployDebugger.logLines = debuggingLogs;
- logReader.debuggerStream = iosDeployDebugger;
- final Future<List<String>> logLines = logReader.logLines.toList();
- final List<String> 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.',
+ final FakeIOSDeployDebugger iosDeployDebugger = FakeIOSDeployDebugger();
+ iosDeployDebugger.debuggerAttached = true;
+ logReader.debuggerStream = iosDeployDebugger;
+
+ 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: <String>[
+ ideviceSyslogPath, '-u', '1234',
+ ],
+ stdout: '''
+ Runner(Flutter)[297] <Notice>: A is for ari
+ Runner(Flutter)[297] <Notice>: I is for ichigo
+ May 30 13:56:28 Runner(Flutter)[2037] <Notice>: flutter: The Dart VM service is listening on http://127.0.0.1:63098/35ZezGIQLnw=/
+ May 30 13:56:28 Runner(Flutter)[2037] <Notice>: flutter: This is a test
+ May 30 13:56:28 Runner(Flutter)[2037] <Notice>: [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<String> lines = await logReader.logLines.toList();
+
+ expect(logReader.useSyslogLogging, isTrue);
+ expect(processManager, hasNoRemainingExpectations);
+ expect(lines, <String>[
+ '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<String>.fromIterable(<String>[]);
+ logReader.debuggerStream = iosDeployDebugger;
+
+ final List<String> 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<String> debuggingLogs = Stream<String>.fromIterable(<String>[
+ 'flutter: Message from debugger',
+ ]);
+ iosDeployDebugger.logLines = debuggingLogs;
+ logReader.debuggerStream = iosDeployDebugger;
+
+ final List<String> lines = await logReader.logLines.toList();
+
+ expect(logReader.useIOSDeployLogging, isTrue);
+ expect(processManager, hasNoRemainingExpectations);
+ expect(lines, <String>[
+ '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<String> debuggingLogs = Stream<String>.fromIterable(<String>[
+ 'flutter: Message from debugger',
+ ]);
+ iosDeployDebugger.logLines = debuggingLogs;
+ logReader.debuggerStream = iosDeployDebugger;
+
+ final List<String> 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: <VmServiceExpectation>[
+ const FakeVmServiceRequest(method: 'streamListen', args: <String, Object>{
+ 'streamId': 'Debug',
+ }),
+ const FakeVmServiceRequest(method: 'streamListen', args: <String, Object>{
+ 'streamId': 'Stdout',
+ }),
+ const FakeVmServiceRequest(method: 'streamListen', args: <String, Object>{
+ '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(<Matcher>[
+ 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: <VmServiceExpectation>[
+ const FakeVmServiceRequest(method: 'streamListen', args: <String, Object>{
+ 'streamId': 'Debug',
+ }),
+ const FakeVmServiceRequest(method: 'streamListen', args: <String, Object>{
+ 'streamId': 'Stdout',
+ }),
+ const FakeVmServiceRequest(method: 'streamListen', args: <String, Object>{
+ '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<String> 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<List<String>> 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<String> lines = await logLines;
+
+ expect(lines, containsAllInOrder(<String>[
+ '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<List<String>> 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<String> lines = await logLines;
+
+ expect(lines, containsAllInOrder(<String>[
+ '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<List<String>> 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<String> lines = await logLines;
+
+ expect(lines, containsAllInOrder(<String>[
+ '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<List<String>> 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<String> lines = await logLines;
+
+ expect(lines, containsAllInOrder(<String>[
+ '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<List<String>> 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<String> lines = await logLines;
+
+ expect(lines, containsAllInOrder(<String>[
+ '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 082d70b..abe1e85 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/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 @@
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 @@
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 be0d51a..d7f354c 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/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 '../../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 @@
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(
+ <String>['Runner'],
+ <String>['Debug', 'Release'],
+ <String>['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: <String, Object>{},
+ );
+
+ expect(fileSystem.directory('build/ios/iphoneos'), exists);
+ expect(launchResult.started, true);
+ expect(processManager, hasNoRemainingExpectations);
+ }, overrides: <Type, Generator>{
+ 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: <String, Object>{},
+ );
+
+ expect(fileSystem.directory('build/ios/iphoneos'), exists);
+ expect(launchResult.started, false);
+ expect(processManager, hasNoRemainingExpectations);
+ }, overrides: <Type, Generator>{
+ 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: <String, Object>{},
+ );
+
+ expect(fileSystem.directory('build/ios/iphoneos'), exists);
+ expect(launchResult.started, false);
+ expect(processManager, hasNoRemainingExpectations);
+ }, overrides: <Type, Generator>{
+ 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: <String, Object>{},
+ );
+
+ 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: <Type, Generator>{
+ 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: <String>['--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: <String, Object>{},
+ );
+
+ expect(logger.errorText, isEmpty);
+ expect(fileSystem.directory('build/ios/iphoneos'), exists);
+ expect(launchResult.started, true);
+ expect(processManager, hasNoRemainingExpectations);
+ }, overrides: <Type, Generator>{
+ 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: <String, Object>{},
+ );
+ expect(logger.errorText, contains('Xcode project not found'));
+ expect(fileSystem.directory('build/ios/iphoneos'), exists);
+ expect(launchResult.started, false);
+ expect(processManager, hasNoRemainingExpectations);
+ }, overrides: <Type, Generator>{
+ 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: <String, Object>{},
+ );
+ expect(logger.errorText, contains('Unable to get Xcode workspace'));
+ expect(fileSystem.directory('build/ios/iphoneos'), exists);
+ expect(launchResult.started, false);
+ expect(processManager, hasNoRemainingExpectations);
+ }, overrides: <Type, Generator>{
+ 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: <String, Object>{},
+ ), throwsToolExit());
+ }, overrides: <Type, Generator>{
+ 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 @@
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 @@
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 @@
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<String>? expectedLaunchArguments;
+
+ @override
+ Future<bool> debugApp({
+ required XcodeDebugProject project,
+ required String deviceId,
+ required List<String> 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<String>? _launchArguments;
+
+ List<String>? get argumentsUsedForLaunch => _launchArguments;
+
+ @override
+ Future<bool> installApp({
+ required String deviceId,
+ required String bundlePath,
+ }) async {
+ return installSuccess;
+ }
+
+ @override
+ Future<bool> launchApp({
+ required String deviceId,
+ required String bundleId,
+ List<String> launchArguments = const <String>[],
+ }) 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 5d01888..8e8e223 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 @@
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: <String>['--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: <String, dynamic>{},
+ );
+
+ 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<void> completer = Completer<void>();
+ final FakeXcodeDebug xcodeDebug = FakeXcodeDebug(
+ expectedProject: XcodeDebugProject(
+ scheme: 'Runner',
+ xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'),
+ xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'),
+ ),
+ expectedDeviceId: '123',
+ expectedLaunchArguments: <String>['--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: <String, dynamic>{},
+ );
+
+ 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: <String>['--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: <String, dynamic>{},
+ 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: <String>['--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: <String, dynamic>{},
+ );
+
+ expect(launchResult.started, true);
+ expect(launchResult.hasVmService, true);
+ expect(await device.stopApp(iosApp), true);
+ }, overrides: <Type, Generator>{
+ MDnsVmServiceDiscovery: () => FakeMDnsVmServiceDiscovery(),
+ });
+ });
+ });
}
IOSDevice setUpIOSDevice({
@@ -610,6 +821,9 @@
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 @@
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 @@
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<String>? expectedLaunchArguments;
+ final String? expectedBundlePath;
+ final Completer<void>? completer;
+
+ @override
+ bool debugStarted = false;
+
+ @override
+ Future<XcodeDebugProject> createXcodeProjectWithCustomBundle(
+ String deviceBundlePath, {
+ required TemplateRenderer templateRenderer,
+ Directory? projectDestination,
+ bool verboseLogging = false,
+ }) async {
+ if (expectedBundlePath != null) {
+ expect(expectedBundlePath, deviceBundlePath);
+ }
+ return expectedProject!;
+ }
+
+ @override
+ Future<bool> debugApp({
+ required XcodeDebugProject project,
+ required String deviceId,
+ required List<String> 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<bool> exit({
+ bool force = false,
+ bool skipDelay = false,
+ }) async {
+ return true;
+ }
+}
+
+class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl {}
+
+class FakeShutDownHooks extends Fake implements ShutdownHooks {
+ List<ShutdownHook> hooks = <ShutdownHook>[];
+ @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 0000000..cbd2416
--- /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>[
+ FakeCommand(
+ command: <String>[
+ '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: <String>[
+ 'open',
+ '-a',
+ pathToXcodeApp,
+ '-g',
+ '-j',
+ xcworkspace.path
+ ],
+ ),
+ FakeCommand(
+ command: <String>[
+ '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: <String>[
+ '--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>[
+ FakeCommand(
+ command: <String>[
+ '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: <String>[
+ 'open',
+ '-a',
+ pathToXcodeApp,
+ '-g',
+ '-j',
+ xcworkspace.path
+ ],
+ ),
+ FakeCommand(
+ command: <String>[
+ '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: <String>[],
+ );
+
+ 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>[
+ FakeCommand(
+ command: <String>[
+ '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: <String>[
+ 'open',
+ '-a',
+ pathToXcodeApp,
+ '-g',
+ '-j',
+ xcworkspace.path
+ ],
+ exception: ProcessException(
+ 'open',
+ <String>[
+ '-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: <String>[
+ '--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>[
+ FakeCommand(
+ command: <String>[
+ '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: <String>[
+ '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: <String>[
+ '--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>[
+ FakeCommand(
+ command: <String>[
+ '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: <String>[
+ '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: <String>[
+ '--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>[
+ FakeCommand(
+ command: <String>[
+ '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: <String>[
+ '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: <String>[
+ '--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>[
+ FakeCommand(
+ command: <String>[
+ '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: <String>[
+ '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: <String>[
+ '--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>[
+ FakeCommand(
+ command: <String>[
+ '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>[
+ FakeCommand(
+ command: <String>[
+ '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>[
+ FakeCommand(
+ command: <String>[
+ '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(<FakeCommand>[
+ const FakeCommand(
+ command: <String>[
+ '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>[
+ FakeCommand(
+ command: <String>[
+ '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>[
+ FakeCommand(
+ command: <String>[
+ '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: <String>['/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 90210c3..3dfe915 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 @@
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: <Type, Generator>{
+ 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: <Type, Generator>{
+ 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: <Type, Generator>{
+ 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 887712a..6df6b82 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 @@
expect(fakeProcessManager, hasNoRemainingExpectations);
});
- testWithoutContext('isSimctlInstalled is true when simctl list fails', () {
+ testWithoutContext('isSimctlInstalled is false when simctl list fails', () {
fakeProcessManager.addCommand(
const FakeCommand(
command: <String>[
@@ -97,6 +101,156 @@
expect(fakeProcessManager, hasNoRemainingExpectations);
});
+ group('isDevicectlInstalled', () {
+ testWithoutContext('is true when Xcode is 15+ and devicectl succeeds', () {
+ fakeProcessManager.addCommand(
+ const FakeCommand(
+ command: <String>[
+ '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: <String>[
+ '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: <String>['/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: <String>[
+ '/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 @@
group('xcdevice not installed', () {
late XCDevice xcdevice;
late Xcode xcode;
+ late MemoryFileSystem fileSystem;
setUp(() {
xcode = Xcode.test(
@@ -348,6 +503,7 @@
version: null, // Not installed.
),
);
+ fileSystem = MemoryFileSystem.test();
xcdevice = XCDevice(
processManager: fakeProcessManager,
logger: logger,
@@ -356,6 +512,9 @@
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 @@
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 @@
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 @@
}, overrides: <Type, Generator>{
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>[
+ 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: <String>['xcrun', 'xcdevice', 'list', '--timeout', '2'],
+ stdout: devicesOutput,
+ ));
+
+ final List<IOSDevice> 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: <Type, Generator>{
+ Platform: () => macPlatform,
+ Artifacts: () => Artifacts.test(),
+ });
+
+ });
});
group('diagnostics', () {
@@ -1312,3 +1648,41 @@
@override
List<String> xcrunCommand() => <String>['xcrun'];
}
+
+class FakeXcodeDebug extends Fake implements XcodeDebug {}
+
+class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl {
+
+ List<FakeIOSCoreDevice> devices = <FakeIOSCoreDevice>[];
+
+ @override
+ Future<List<IOSCoreDevice>> 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 b0f6085..8c68685 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 @@
});
group('for launch', () {
+ testWithoutContext('Ensure either port or device name are provided', () async {
+ final MDnsClient client = FakeMDnsClient(<PtrResourceRecord>[], <String, List<SrvResourceRecord>>{});
+
+ 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(<PtrResourceRecord>[], <String, List<SrvResourceRecord>>{});
@@ -666,6 +678,93 @@
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>[
+ PtrResourceRecord('foo', future, domainName: 'srv-foo'),
+ PtrResourceRecord('bar', future, domainName: 'srv-bar'),
+ PtrResourceRecord('baz', future, domainName: 'srv-boo'),
+ ],
+ <String, List<SrvResourceRecord>>{
+ 'srv-bar': <SrvResourceRecord>[
+ 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>[
+ PtrResourceRecord('foo', future, domainName: 'srv-foo'),
+ PtrResourceRecord('bar', future, domainName: 'srv-bar'),
+ PtrResourceRecord('baz', future, domainName: 'srv-boo'),
+ ],
+ <String, List<SrvResourceRecord>>{
+ 'srv-foo': <SrvResourceRecord>[
+ 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(
+ <PtrResourceRecord>[],
+ <String, List<SrvResourceRecord>>{},
+ ),
+ 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(
+ <PtrResourceRecord>[],
+ <String, List<SrvResourceRecord>>{},
+ ),
+ 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 @@
// 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<TargetPlatform> get targetPlatform async => TargetPlatform.ios;