diff --git a/.ci.yaml b/.ci.yaml index 65067287e2e..2ca4af28d2a 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -3685,6 +3685,16 @@ targets: ["devicelab", "ios", "mac"] task_name: flutter_gallery_ios__start_up + - name: Mac_ios flutter_gallery_ios__start_up_xcode_debug + recipe: devicelab/devicelab_drone + presubmit: false + timeout: 60 + properties: + tags: > + ["devicelab", "ios", "mac"] + task_name: flutter_gallery_ios__start_up_xcode_debug + bringup: true + - name: Mac_ios flutter_view_ios__start_up recipe: devicelab/devicelab_drone presubmit: false @@ -3752,6 +3762,16 @@ targets: ["devicelab", "ios", "mac"] task_name: integration_ui_ios_driver + - name: Mac_ios integration_ui_ios_driver_xcode_debug + recipe: devicelab/devicelab_drone + presubmit: false + timeout: 60 + properties: + tags: > + ["devicelab", "ios", "mac"] + task_name: integration_ui_ios_driver_xcode_debug + bringup: true + - name: Mac_ios integration_ui_ios_frame_number recipe: devicelab/devicelab_drone presubmit: false diff --git a/TESTOWNERS b/TESTOWNERS index 11a3dc6eb9a..dfd325dd477 100644 --- a/TESTOWNERS +++ b/TESTOWNERS @@ -168,6 +168,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 @@ -178,6 +179,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 00000000000..a17c45d2d86 --- /dev/null +++ b/dev/devicelab/bin/tasks/flutter_gallery_ios__start_up_xcode_debug.dart @@ -0,0 +1,21 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_devicelab/framework/devices.dart'; +import 'package:flutter_devicelab/framework/framework.dart'; +import 'package:flutter_devicelab/tasks/perf_tests.dart'; + +Future main() async { + // TODO(vashworth): Remove after Xcode 15 and iOS 17 are in CI (https://github.com/flutter/flutter/issues/132128) + // XcodeDebug workflow is used for CoreDevices (iOS 17+ and Xcode 15+). Use + // FORCE_XCODE_DEBUG environment variable to force the use of XcodeDebug + // workflow in CI to test from older versions since devicelab has not yet been + // updated to iOS 17 and Xcode 15. + deviceOperatingSystem = DeviceOperatingSystem.ios; + await task(createFlutterGalleryStartupTest( + runEnvironment: { + 'FORCE_XCODE_DEBUG': 'true', + }, + )); +} diff --git a/dev/devicelab/bin/tasks/integration_ui_ios_driver_xcode_debug.dart b/dev/devicelab/bin/tasks/integration_ui_ios_driver_xcode_debug.dart new file mode 100644 index 00000000000..f51b231d295 --- /dev/null +++ b/dev/devicelab/bin/tasks/integration_ui_ios_driver_xcode_debug.dart @@ -0,0 +1,21 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_devicelab/framework/devices.dart'; +import 'package:flutter_devicelab/framework/framework.dart'; +import 'package:flutter_devicelab/tasks/integration_tests.dart'; + +Future main() async { + // TODO(vashworth): Remove after Xcode 15 and iOS 17 are in CI (https://github.com/flutter/flutter/issues/132128) + // XcodeDebug workflow is used for CoreDevices (iOS 17+ and Xcode 15+). Use + // FORCE_XCODE_DEBUG environment variable to force the use of XcodeDebug + // workflow in CI to test from older versions since devicelab has not yet been + // updated to iOS 17 and Xcode 15. + deviceOperatingSystem = DeviceOperatingSystem.ios; + await task(createEndToEndDriverTest( + environment: { + 'FORCE_XCODE_DEBUG': 'true', + }, + )); +} diff --git a/dev/devicelab/lib/tasks/integration_tests.dart b/dev/devicelab/lib/tasks/integration_tests.dart index 0172d576fe4..c06e717874d 100644 --- a/dev/devicelab/lib/tasks/integration_tests.dart +++ b/dev/devicelab/lib/tasks/integration_tests.dart @@ -106,10 +106,11 @@ TaskFunction createEndToEndFrameNumberTest() { ).call; } -TaskFunction createEndToEndDriverTest() { +TaskFunction createEndToEndDriverTest({Map? environment}) { return DriverTest( '${flutterDirectory.path}/dev/integration_tests/ui', 'lib/driver.dart', + environment: environment, ).call; } @@ -173,6 +174,7 @@ class DriverTest { this.testTarget, { this.extraOptions = const [], this.deviceIdOverride, + this.environment, } ); @@ -180,6 +182,7 @@ class DriverTest { final String testTarget; final List extraOptions; final String? deviceIdOverride; + final Map? environment; Future call() { return inDirectory(testDirectory, () async { @@ -202,7 +205,7 @@ class DriverTest { deviceId, ...extraOptions, ]; - await flutter('drive', options: options); + await flutter('drive', options: options, environment: environment); return TaskResult.success(null); }); diff --git a/dev/devicelab/lib/tasks/perf_tests.dart b/dev/devicelab/lib/tasks/perf_tests.dart index ca408a694c7..9416e505b2d 100644 --- a/dev/devicelab/lib/tasks/perf_tests.dart +++ b/dev/devicelab/lib/tasks/perf_tests.dart @@ -233,10 +233,11 @@ TaskFunction createOpenPayScrollPerfTest({bool measureCpuGpu = true}) { ).run; } -TaskFunction createFlutterGalleryStartupTest({String target = 'lib/main.dart'}) { +TaskFunction createFlutterGalleryStartupTest({String target = 'lib/main.dart', Map? runEnvironment}) { return StartupTest( '${flutterDirectory.path}/dev/integration_tests/flutter_gallery', target: target, + runEnvironment: runEnvironment, ).run; } @@ -768,11 +769,17 @@ Future _resetManifest(String testDirectory) async { /// Measure application startup performance. class StartupTest { - const StartupTest(this.testDirectory, { this.reportMetrics = true, this.target = 'lib/main.dart' }); + const StartupTest( + this.testDirectory, { + this.reportMetrics = true, + this.target = 'lib/main.dart', + this.runEnvironment, + }); final String testDirectory; final bool reportMetrics; final String target; + final Map? runEnvironment; Future run() async { return inDirectory(testDirectory, () async { @@ -855,21 +862,26 @@ class StartupTest { 'screenshot_startup_${DateTime.now().toLocal().toIso8601String()}.png', ); }); - final int result = await flutter('run', options: [ - '--no-android-gradle-daemon', - '--no-publish-port', - '--verbose', - '--profile', - '--trace-startup', - // TODO(vashworth): Remove once done debugging https://github.com/flutter/flutter/issues/129836 - if (device is IosDevice) - '--verbose-system-logs', - '--target=$target', - '-d', - device.deviceId, - if (applicationBinaryPath != null) - '--use-application-binary=$applicationBinaryPath', - ], canFail: true); + final int result = await flutter( + 'run', + options: [ + '--no-android-gradle-daemon', + '--no-publish-port', + '--verbose', + '--profile', + '--trace-startup', + // TODO(vashworth): Remove once done debugging https://github.com/flutter/flutter/issues/129836 + if (device is IosDevice) + '--verbose-system-logs', + '--target=$target', + '-d', + device.deviceId, + if (applicationBinaryPath != null) + '--use-application-binary=$applicationBinaryPath', + ], + environment: runEnvironment, + canFail: true, + ); timer.cancel(); if (result == 0) { final Map data = json.decode( diff --git a/packages/flutter_tools/bin/xcode_debug.js b/packages/flutter_tools/bin/xcode_debug.js new file mode 100644 index 00000000000..25f16a27298 --- /dev/null +++ b/packages/flutter_tools/bin/xcode_debug.js @@ -0,0 +1,530 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * @fileoverview OSA Script to interact with Xcode. Functionality includes + * checking if a given project is open in Xcode, starting a debug session for + * a given project, and stopping a debug session for a given project. + */ + +'use strict'; + +/** + * OSA Script `run` handler that is called when the script is run. When ran + * with `osascript`, arguments are passed from the command line to the direct + * parameter of the `run` handler as a list of strings. + * + * @param {?Array=} args_array + * @returns {!RunJsonResponse} The validated command. + */ +function run(args_array = []) { + let args; + try { + args = new CommandArguments(args_array); + } catch (e) { + return new RunJsonResponse(false, `Failed to parse arguments: ${e}`).stringify(); + } + + const xcodeResult = getXcode(args); + if (xcodeResult.error != null) { + return new RunJsonResponse(false, xcodeResult.error).stringify(); + } + const xcode = xcodeResult.result; + + if (args.command === 'check-workspace-opened') { + const result = getWorkspaceDocument(xcode, args); + return new RunJsonResponse(result.error == null, result.error).stringify(); + } else if (args.command === 'debug') { + const result = debugApp(xcode, args); + return new RunJsonResponse(result.error == null, result.error, result.result).stringify(); + } else if (args.command === 'stop') { + const result = stopApp(xcode, args); + return new RunJsonResponse(result.error == null, result.error).stringify(); + } else { + return new RunJsonResponse(false, 'Unknown command').stringify(); + } +} + +/** + * Parsed and validated arguments passed from the command line. + */ +class CommandArguments { + /** + * + * @param {!Array} args List of arguments passed from the command line. + */ + constructor(args) { + this.command = this.validatedCommand(args[0]); + + const parsedArguments = this.parseArguments(args); + + this.xcodePath = this.validatedStringArgument('--xcode-path', parsedArguments['--xcode-path']); + this.projectPath = this.validatedStringArgument('--project-path', parsedArguments['--project-path']); + this.workspacePath = this.validatedStringArgument('--workspace-path', parsedArguments['--workspace-path']); + this.targetDestinationId = this.validatedStringArgument('--device-id', parsedArguments['--device-id']); + this.targetSchemeName = this.validatedStringArgument('--scheme', parsedArguments['--scheme']); + this.skipBuilding = this.validatedBoolArgument('--skip-building', parsedArguments['--skip-building']); + this.launchArguments = this.validatedJsonArgument('--launch-args', parsedArguments['--launch-args']); + this.closeWindowOnStop = this.validatedBoolArgument('--close-window', parsedArguments['--close-window']); + this.promptToSaveBeforeClose = this.validatedBoolArgument('--prompt-to-save', parsedArguments['--prompt-to-save']); + this.verbose = this.validatedBoolArgument('--verbose', parsedArguments['--verbose']); + + if (this.verbose === true) { + console.log(JSON.stringify(this)); + } + } + + /** + * Validates the command is available. + * + * @param {?string} command + * @returns {!string} The validated command. + * @throws Will throw an error if command is not recognized. + */ + validatedCommand(command) { + const allowedCommands = ['check-workspace-opened', 'debug', 'stop']; + if (allowedCommands.includes(command) === false) { + throw `Unrecognized Command: ${command}`; + } + + return command; + } + + /** + * Validates the flag is allowed for the current command. + * + * @param {!string} flag + * @param {?string} value + * @returns {!bool} + * @throws Will throw an error if the flag is not allowed for the current + * command and the value is not null, undefined, or empty. + */ + isArgumentAllowed(flag, value) { + const allowedArguments = { + 'common': { + '--xcode-path': true, + '--project-path': true, + '--workspace-path': true, + '--verbose': true, + }, + 'check-workspace-opened': {}, + 'debug': { + '--device-id': true, + '--scheme': true, + '--skip-building': true, + '--launch-args': true, + }, + 'stop': { + '--close-window': true, + '--prompt-to-save': true, + }, + } + + const isAllowed = allowedArguments['common'][flag] === true || allowedArguments[this.command][flag] === true; + if (isAllowed === false && (value != null && value !== '')) { + throw `The flag ${flag} is not allowed for the command ${this.command}.`; + } + return isAllowed; + } + + /** + * Parses the command line arguments into an object. + * + * @param {!Array} args List of arguments passed from the command line. + * @returns {!Object.} Object mapping flag to value. + * @throws Will throw an error if flag does not begin with '--'. + */ + parseArguments(args) { + const valuesPerFlag = {}; + for (let index = 1; index < args.length; index++) { + const entry = args[index]; + let flag; + let value; + const splitIndex = entry.indexOf('='); + if (splitIndex === -1) { + flag = entry; + value = args[index + 1]; + + // If the flag is allowed for the command, and the next value in the + // array is null/undefined or also a flag, treat the flag like a boolean + // flag and set the value to 'true'. + if (this.isArgumentAllowed(flag) && (value == null || value.startsWith('--'))) { + value = 'true'; + } else { + index++; + } + } else { + flag = entry.substring(0, splitIndex); + value = entry.substring(splitIndex + 1, entry.length + 1); + } + if (flag.startsWith('--') === false) { + throw `Unrecognized Flag: ${flag}`; + } + + valuesPerFlag[flag] = value; + } + return valuesPerFlag; + } + + + /** + * Validates the flag is allowed and `value` is valid. If the flag is not + * allowed for the current command, return `null`. + * + * @param {!string} flag + * @param {?string} value + * @returns {!string} + * @throws Will throw an error if the flag is allowed and `value` is null, + * undefined, or empty. + */ + validatedStringArgument(flag, value) { + if (this.isArgumentAllowed(flag, value) === false) { + return null; + } + if (value == null || value === '') { + throw `Missing value for ${flag}`; + } + return value; + } + + /** + * Validates the flag is allowed, validates `value` is valid, and converts + * `value` to a boolean. A `value` of null, undefined, or empty, it will + * return true. If the flag is not allowed for the current command, will + * return `null`. + * + * @param {?string} value + * @returns {?boolean} + * @throws Will throw an error if the flag is allowed and `value` is not + * null, undefined, empty, 'true', or 'false'. + */ + validatedBoolArgument(flag, value) { + if (this.isArgumentAllowed(flag, value) === false) { + return null; + } + if (value == null || value === '') { + return false; + } + if (value !== 'true' && value !== 'false') { + throw `Invalid value for ${flag}`; + } + return value === 'true'; + } + + /** + * Validates the flag is allowed, `value` is valid, and parses `value` as JSON. + * If the flag is not allowed for the current command, will return `null`. + * + * @param {!string} flag + * @param {?string} value + * @returns {!Object} + * @throws Will throw an error if the flag is allowed and the value is + * null, undefined, or empty. Will also throw an error if parsing fails. + */ + validatedJsonArgument(flag, value) { + if (this.isArgumentAllowed(flag, value) === false) { + return null; + } + if (value == null || value === '') { + throw `Missing value for ${flag}`; + } + try { + return JSON.parse(value); + } catch (e) { + throw `Error parsing ${flag}: ${e}`; + } + } +} + +/** + * Response to return in `run` function. + */ +class RunJsonResponse { + /** + * + * @param {!bool} success Whether the command was successful. + * @param {?string=} errorMessage Defaults to null. + * @param {?DebugResult=} debugResult Curated results from Xcode's debug + * function. Defaults to null. + */ + constructor(success, errorMessage = null, debugResult = null) { + this.status = success; + this.errorMessage = errorMessage; + this.debugResult = debugResult; + } + + /** + * Converts this object to a JSON string. + * + * @returns {!string} + * @throws Throws an error if conversion fails. + */ + stringify() { + return JSON.stringify(this); + } +} + +/** + * Utility class to return a result along with a potential error. + */ +class FunctionResult { + /** + * + * @param {?Object} result + * @param {?string=} error Defaults to null. + */ + constructor(result, error = null) { + this.result = result; + this.error = error; + } +} + +/** + * Curated results from Xcode's debug function. Mirrors parts of + * `scheme action result` from Xcode's Script Editor dictionary. + */ +class DebugResult { + /** + * + * @param {!Object} result + */ + constructor(result) { + this.completed = result.completed(); + this.status = result.status(); + this.errorMessage = result.errorMessage(); + } +} + +/** + * Get the Xcode application from the given path. Since macs can have multiple + * Xcode version, we use the path to target the specific Xcode application. + * If the Xcode app is not running, return null with an error. + * + * @param {!CommandArguments} args + * @returns {!FunctionResult} Return either an `Application` (Mac Scripting class) + * or null as the `result`. + */ +function getXcode(args) { + try { + const xcode = Application(args.xcodePath); + const isXcodeRunning = xcode.running(); + + if (isXcodeRunning === false) { + return new FunctionResult(null, 'Xcode is not running'); + } + + return new FunctionResult(xcode); + } catch (e) { + return new FunctionResult(null, `Failed to get Xcode application: ${e}`); + } +} + +/** + * After setting the active run destination to the targeted device, uses Xcode + * debug function from Mac Scripting for Xcode to install the app on the device + * and start a debugging session using the 'run' or 'run without building' scheme + * action (depending on `args.skipBuilding`). Waits for the debugging session + * to start running. + * + * @param {!Application} xcode An `Application` (Mac Scripting class) for Xcode. + * @param {!CommandArguments} args + * @returns {!FunctionResult} Return either a `DebugResult` or null as the `result`. + */ +function debugApp(xcode, args) { + const workspaceResult = waitForWorkspaceToLoad(xcode, args); + if (workspaceResult.error != null) { + return new FunctionResult(null, workspaceResult.error); + } + const targetWorkspace = workspaceResult.result; + + const destinationResult = getTargetDestination( + targetWorkspace, + args.targetDestinationId, + args.verbose, + ); + if (destinationResult.error != null) { + return new FunctionResult(null, destinationResult.error) + } + + try { + // Documentation from the Xcode Script Editor dictionary indicates that the + // `debug` function has a parameter called `runDestinationSpecifier` which + // is used to specify which device to debug the app on. It also states that + // it should be the same as the xcodebuild -destination specifier. It also + // states that if not specified, the `activeRunDestination` is used instead. + // + // Experimentation has shown that the `runDestinationSpecifier` does not work. + // It will always use the `activeRunDestination`. To mitigate this, we set + // the `activeRunDestination` to the targeted device prior to starting the debug. + targetWorkspace.activeRunDestination = destinationResult.result; + + const actionResult = targetWorkspace.debug({ + scheme: args.targetSchemeName, + skipBuilding: args.skipBuilding, + commandLineArguments: args.launchArguments, + }); + + // Wait until scheme action has started up to a max of 10 minutes. + // This does not wait for app to install, launch, or start debug session. + // Potential statuses include: not yet started/‌running/‌cancelled/‌failed/‌error occurred/‌succeeded. + const checkFrequencyInSeconds = 0.5; + const maxWaitInSeconds = 10 * 60; // 10 minutes + const iterations = maxWaitInSeconds * (1 / checkFrequencyInSeconds); + const verboseLogInterval = 10 * (1 / checkFrequencyInSeconds); + for (let i = 0; i < iterations; i++) { + if (actionResult.status() !== 'not yet started') { + break; + } + if (args.verbose === true && i % verboseLogInterval === 0) { + console.log(`Action result status: ${actionResult.status()}`); + } + delay(checkFrequencyInSeconds); + } + + return new FunctionResult(new DebugResult(actionResult)); + } catch (e) { + return new FunctionResult(null, `Failed to start debugging session: ${e}`); + } +} + +/** + * Iterates through available run destinations looking for one with a matching + * `deviceId`. If device is not found, return null with an error. + * + * @param {!WorkspaceDocument} targetWorkspace A `WorkspaceDocument` (Xcode Mac + * Scripting class). + * @param {!string} deviceId + * @param {?bool=} verbose Defaults to false. + * @returns {!FunctionResult} Return either a `RunDestination` (Xcode Mac + * Scripting class) or null as the `result`. + */ +function getTargetDestination(targetWorkspace, deviceId, verbose = false) { + try { + for (let destination of targetWorkspace.runDestinations()) { + const device = destination.device(); + if (verbose === true && device != null) { + console.log(`Device: ${device.name()} (${device.deviceIdentifier()})`); + } + if (device != null && device.deviceIdentifier() === deviceId) { + return new FunctionResult(destination); + } + } + return new FunctionResult( + null, + 'Unable to find target device. Ensure that the device is paired, ' + + 'unlocked, connected, and has an iOS version at least as high as the ' + + 'Minimum Deployment.', + ); + } catch (e) { + return new FunctionResult(null, `Failed to get target destination: ${e}`); + } +} + +/** + * Waits for the workspace to load. If the workspace is not loaded or in the + * process of opening, it will wait up to 10 minutes. + * + * @param {!Application} xcode An `Application` (Mac Scripting class) for Xcode. + * @param {!CommandArguments} args + * @returns {!FunctionResult} Return either a `WorkspaceDocument` (Xcode Mac + * Scripting class) or null as the `result`. + */ +function waitForWorkspaceToLoad(xcode, args) { + try { + const checkFrequencyInSeconds = 0.5; + const maxWaitInSeconds = 10 * 60; // 10 minutes + const verboseLogInterval = 10 * (1 / checkFrequencyInSeconds); + const iterations = maxWaitInSeconds * (1 / checkFrequencyInSeconds); + for (let i = 0; i < iterations; i++) { + // Every 10 seconds, print the list of workspaces if verbose + const verbose = args.verbose && i % verboseLogInterval === 0; + + const workspaceResult = getWorkspaceDocument(xcode, args, verbose); + if (workspaceResult.error == null) { + const document = workspaceResult.result; + if (document.loaded() === true) { + return new FunctionResult(document, null); + } + } else if (verbose === true) { + console.log(workspaceResult.error); + } + delay(checkFrequencyInSeconds); + } + return new FunctionResult(null, 'Timed out waiting for workspace to load'); + } catch (e) { + return new FunctionResult(null, `Failed to wait for workspace to load: ${e}`); + } +} + +/** + * Gets workspace opened in Xcode matching the projectPath or workspacePath + * from the command line arguments. If workspace is not found, return null with + * an error. + * + * @param {!Application} xcode An `Application` (Mac Scripting class) for Xcode. + * @param {!CommandArguments} args + * @param {?bool=} verbose Defaults to false. + * @returns {!FunctionResult} Return either a `WorkspaceDocument` (Xcode Mac + * Scripting class) or null as the `result`. + */ +function getWorkspaceDocument(xcode, args, verbose = false) { + const privatePrefix = '/private'; + + try { + const documents = xcode.workspaceDocuments(); + for (let document of documents) { + const filePath = document.file().toString(); + if (verbose === true) { + console.log(`Workspace: ${filePath}`); + } + if (filePath === args.projectPath || filePath === args.workspacePath) { + return new FunctionResult(document); + } + // Sometimes when the project is in a temporary directory, it'll be + // prefixed with `/private` but the args will not. Remove the + // prefix before matching. + if (filePath.startsWith(privatePrefix) === true) { + const filePathWithoutPrefix = filePath.slice(privatePrefix.length); + if (filePathWithoutPrefix === args.projectPath || filePathWithoutPrefix === args.workspacePath) { + return new FunctionResult(document); + } + } + } + } catch (e) { + return new FunctionResult(null, `Failed to get workspace: ${e}`); + } + return new FunctionResult(null, `Failed to get workspace.`); +} + +/** + * Stops all debug sessions in the target workspace. + * + * @param {!Application} xcode An `Application` (Mac Scripting class) for Xcode. + * @param {!CommandArguments} args + * @returns {!FunctionResult} Always returns null as the `result`. + */ +function stopApp(xcode, args) { + const workspaceResult = getWorkspaceDocument(xcode, args); + if (workspaceResult.error != null) { + return new FunctionResult(null, workspaceResult.error); + } + const targetDocument = workspaceResult.result; + + try { + targetDocument.stop(); + + if (args.closeWindowOnStop === true) { + // Wait a couple seconds before closing Xcode, otherwise it'll prompt the + // user to stop the app. + delay(2); + + targetDocument.close({ + saving: args.promptToSaveBeforeClose === true ? 'ask' : 'no', + }); + } + } catch (e) { + return new FunctionResult(null, `Failed to stop app: ${e}`); + } + return new FunctionResult(null, null); +} diff --git a/packages/flutter_tools/lib/src/context_runner.dart b/packages/flutter_tools/lib/src/context_runner.dart index 08cefe5169c..a5a4f92c250 100644 --- a/packages/flutter_tools/lib/src/context_runner.dart +++ b/packages/flutter_tools/lib/src/context_runner.dart @@ -359,6 +359,7 @@ Future runInContext( platform: globals.platform, fileSystem: globals.fs, xcodeProjectInterpreter: globals.xcodeProjectInterpreter!, + userMessages: globals.userMessages, ), XCDevice: () => XCDevice( processManager: globals.processManager, @@ -375,6 +376,7 @@ Future runInContext( processManager: globals.processManager, dyLdLibEntry: globals.cache.dyLdLibEntry, ), + fileSystem: globals.fs, ), XcodeProjectInterpreter: () => XcodeProjectInterpreter( logger: globals.logger, diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart index c789e31652a..4f209b09e92 100644 --- a/packages/flutter_tools/lib/src/device.dart +++ b/packages/flutter_tools/lib/src/device.dart @@ -1159,6 +1159,7 @@ class DebuggingOptions { Map platformArgs, { bool ipv6 = false, DeviceConnectionInterface interfaceType = DeviceConnectionInterface.attached, + bool isCoreDevice = false, }) { final String dartVmFlags = computeDartVmFlags(this); return [ @@ -1172,7 +1173,10 @@ class DebuggingOptions { if (environmentType == EnvironmentType.simulator && dartVmFlags.isNotEmpty) '--dart-flags=$dartVmFlags', if (useTestFonts) '--use-test-fonts', - if (debuggingEnabled) ...[ + // Core Devices (iOS 17 devices) are debugged through Xcode so don't + // include these flags, which are used to check if the app was launched + // via Flutter CLI and `ios-deploy`. + if (debuggingEnabled && !isCoreDevice) ...[ '--enable-checked-mode', '--verify-entry-points', ], diff --git a/packages/flutter_tools/lib/src/ios/core_devices.dart b/packages/flutter_tools/lib/src/ios/core_devices.dart new file mode 100644 index 00000000000..aa39adbf66c --- /dev/null +++ b/packages/flutter_tools/lib/src/ios/core_devices.dart @@ -0,0 +1,854 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:meta/meta.dart'; +import 'package:process/process.dart'; + +import '../base/file_system.dart'; +import '../base/io.dart'; +import '../base/logger.dart'; +import '../base/process.dart'; +import '../convert.dart'; +import '../device.dart'; +import '../macos/xcode.dart'; + +/// A wrapper around the `devicectl` command line tool. +/// +/// CoreDevice is a device connectivity stack introduced in Xcode 15. Devices +/// with iOS 17 or greater are CoreDevices. +/// +/// `devicectl` (CoreDevice Device Control) is an Xcode CLI tool used for +/// interacting with CoreDevices. +class IOSCoreDeviceControl { + IOSCoreDeviceControl({ + required Logger logger, + required ProcessManager processManager, + required Xcode xcode, + required FileSystem fileSystem, + }) : _logger = logger, + _processUtils = ProcessUtils(logger: logger, processManager: processManager), + _xcode = xcode, + _fileSystem = fileSystem; + + final Logger _logger; + final ProcessUtils _processUtils; + final Xcode _xcode; + final FileSystem _fileSystem; + + /// When the `--timeout` flag is used with `devicectl`, it must be at + /// least 5 seconds. If lower than 5 seconds, `devicectl` will error and not + /// run the command. + static const int _minimumTimeoutInSeconds = 5; + + /// Executes `devicectl` command to get list of devices. The command will + /// likely complete before [timeout] is reached. If [timeout] is reached, + /// the command will be stopped as a failure. + Future> _listCoreDevices({ + Duration timeout = const Duration(seconds: _minimumTimeoutInSeconds), + }) async { + if (!_xcode.isDevicectlInstalled) { + _logger.printError('devicectl is not installed.'); + return []; + } + + // Default to minimum timeout if needed to prevent error. + Duration validTimeout = timeout; + if (timeout.inSeconds < _minimumTimeoutInSeconds) { + _logger.printError( + 'Timeout of ${timeout.inSeconds} seconds is below the minimum timeout value ' + 'for devicectl. Changing the timeout to the minimum value of $_minimumTimeoutInSeconds.'); + validTimeout = const Duration(seconds: _minimumTimeoutInSeconds); + } + + final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('core_devices.'); + final File output = tempDirectory.childFile('core_device_list.json'); + output.createSync(); + + final List command = [ + ..._xcode.xcrunCommand(), + 'devicectl', + 'list', + 'devices', + '--timeout', + validTimeout.inSeconds.toString(), + '--json-output', + output.path, + ]; + + try { + await _processUtils.run(command, throwOnError: true); + + final String stringOutput = output.readAsStringSync(); + _logger.printTrace(stringOutput); + + try { + final Object? decodeResult = (json.decode(stringOutput) as Map)['result']; + if (decodeResult is Map) { + final Object? decodeDevices = decodeResult['devices']; + if (decodeDevices is List) { + return decodeDevices; + } + } + _logger.printError('devicectl returned unexpected JSON response: $stringOutput'); + return []; + } on FormatException { + // We failed to parse the devicectl output, or it returned junk. + _logger.printError('devicectl returned non-JSON response: $stringOutput'); + return []; + } + } on ProcessException catch (err) { + _logger.printError('Error executing devicectl: $err'); + return []; + } finally { + tempDirectory.deleteSync(recursive: true); + } + } + + Future> getCoreDevices({ + Duration timeout = const Duration(seconds: _minimumTimeoutInSeconds), + }) async { + final List devices = []; + + final List devicesSection = await _listCoreDevices(timeout: timeout); + for (final Object? deviceObject in devicesSection) { + if (deviceObject is Map) { + devices.add(IOSCoreDevice.fromBetaJson(deviceObject, logger: _logger)); + } + } + return devices; + } + + /// Executes `devicectl` command to get list of apps installed on the device. + /// If [bundleId] is provided, it will only return apps matching the bundle + /// identifier exactly. + Future> _listInstalledApps({ + required String deviceId, + String? bundleId, + }) async { + if (!_xcode.isDevicectlInstalled) { + _logger.printError('devicectl is not installed.'); + return []; + } + + final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('core_devices.'); + final File output = tempDirectory.childFile('core_device_app_list.json'); + output.createSync(); + + final List command = [ + ..._xcode.xcrunCommand(), + 'devicectl', + 'device', + 'info', + 'apps', + '--device', + deviceId, + if (bundleId != null) + '--bundle-id', + bundleId!, + '--json-output', + output.path, + ]; + + try { + await _processUtils.run(command, throwOnError: true); + + final String stringOutput = output.readAsStringSync(); + + try { + final Object? decodeResult = (json.decode(stringOutput) as Map)['result']; + if (decodeResult is Map) { + final Object? decodeApps = decodeResult['apps']; + if (decodeApps is List) { + return decodeApps; + } + } + _logger.printError('devicectl returned unexpected JSON response: $stringOutput'); + return []; + } on FormatException { + // We failed to parse the devicectl output, or it returned junk. + _logger.printError('devicectl returned non-JSON response: $stringOutput'); + return []; + } + } on ProcessException catch (err) { + _logger.printError('Error executing devicectl: $err'); + return []; + } finally { + tempDirectory.deleteSync(recursive: true); + } + } + + @visibleForTesting + Future> getInstalledApps({ + required String deviceId, + String? bundleId, + }) async { + final List apps = []; + + final List appsData = await _listInstalledApps(deviceId: deviceId, bundleId: bundleId); + for (final Object? appObject in appsData) { + if (appObject is Map) { + apps.add(IOSCoreDeviceInstalledApp.fromBetaJson(appObject)); + } + } + return apps; + } + + Future isAppInstalled({ + required String deviceId, + required String bundleId, + }) async { + final List apps = await getInstalledApps( + deviceId: deviceId, + bundleId: bundleId, + ); + if (apps.isNotEmpty) { + return true; + } + return false; + } + + Future installApp({ + required String deviceId, + required String bundlePath, + }) async { + if (!_xcode.isDevicectlInstalled) { + _logger.printError('devicectl is not installed.'); + return false; + } + + final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('core_devices.'); + final File output = tempDirectory.childFile('install_results.json'); + output.createSync(); + + final List command = [ + ..._xcode.xcrunCommand(), + 'devicectl', + 'device', + 'install', + 'app', + '--device', + deviceId, + bundlePath, + '--json-output', + output.path, + ]; + + try { + await _processUtils.run(command, throwOnError: true); + final String stringOutput = output.readAsStringSync(); + + try { + final Object? decodeResult = (json.decode(stringOutput) as Map)['info']; + if (decodeResult is Map && decodeResult['outcome'] == 'success') { + return true; + } + _logger.printError('devicectl returned unexpected JSON response: $stringOutput'); + return false; + } on FormatException { + // We failed to parse the devicectl output, or it returned junk. + _logger.printError('devicectl returned non-JSON response: $stringOutput'); + return false; + } + } on ProcessException catch (err) { + _logger.printError('Error executing devicectl: $err'); + return false; + } finally { + tempDirectory.deleteSync(recursive: true); + } + } + + /// Uninstalls the app from the device. Will succeed even if the app is not + /// currently installed on the device. + Future uninstallApp({ + required String deviceId, + required String bundleId, + }) async { + if (!_xcode.isDevicectlInstalled) { + _logger.printError('devicectl is not installed.'); + return false; + } + + final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('core_devices.'); + final File output = tempDirectory.childFile('uninstall_results.json'); + output.createSync(); + + final List command = [ + ..._xcode.xcrunCommand(), + 'devicectl', + 'device', + 'uninstall', + 'app', + '--device', + deviceId, + bundleId, + '--json-output', + output.path, + ]; + + try { + await _processUtils.run(command, throwOnError: true); + final String stringOutput = output.readAsStringSync(); + + try { + final Object? decodeResult = (json.decode(stringOutput) as Map)['info']; + if (decodeResult is Map && decodeResult['outcome'] == 'success') { + return true; + } + _logger.printError('devicectl returned unexpected JSON response: $stringOutput'); + return false; + } on FormatException { + // We failed to parse the devicectl output, or it returned junk. + _logger.printError('devicectl returned non-JSON response: $stringOutput'); + return false; + } + } on ProcessException catch (err) { + _logger.printError('Error executing devicectl: $err'); + return false; + } finally { + tempDirectory.deleteSync(recursive: true); + } + } + + Future launchApp({ + required String deviceId, + required String bundleId, + List launchArguments = const [], + }) async { + if (!_xcode.isDevicectlInstalled) { + _logger.printError('devicectl is not installed.'); + return false; + } + + final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('core_devices.'); + final File output = tempDirectory.childFile('launch_results.json'); + output.createSync(); + + final List command = [ + ..._xcode.xcrunCommand(), + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + if (launchArguments.isNotEmpty) ...launchArguments, + '--json-output', + output.path, + ]; + + try { + await _processUtils.run(command, throwOnError: true); + final String stringOutput = output.readAsStringSync(); + + try { + final Object? decodeResult = (json.decode(stringOutput) as Map)['info']; + if (decodeResult is Map && decodeResult['outcome'] == 'success') { + return true; + } + _logger.printError('devicectl returned unexpected JSON response: $stringOutput'); + return false; + } on FormatException { + // We failed to parse the devicectl output, or it returned junk. + _logger.printError('devicectl returned non-JSON response: $stringOutput'); + return false; + } + } on ProcessException catch (err) { + _logger.printError('Error executing devicectl: $err'); + return false; + } finally { + tempDirectory.deleteSync(recursive: true); + } + } +} + +class IOSCoreDevice { + IOSCoreDevice._({ + required this.capabilities, + required this.connectionProperties, + required this.deviceProperties, + required this.hardwareProperties, + required this.coreDeviceIdentifer, + required this.visibilityClass, + }); + + /// Parse JSON from `devicectl list devices --json-output` while it's in beta preview mode. + /// + /// Example: + /// { + /// "capabilities" : [ + /// ], + /// "connectionProperties" : { + /// }, + /// "deviceProperties" : { + /// }, + /// "hardwareProperties" : { + /// }, + /// "identifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + /// "visibilityClass" : "default" + /// } + factory IOSCoreDevice.fromBetaJson( + Map data, { + required Logger logger, + }) { + final List<_IOSCoreDeviceCapability> capabilitiesList = <_IOSCoreDeviceCapability>[]; + if (data['capabilities'] is List) { + final List capabilitiesData = data['capabilities']! as List; + for (final Object? capabilityData in capabilitiesData) { + if (capabilityData != null && capabilityData is Map) { + capabilitiesList.add(_IOSCoreDeviceCapability.fromBetaJson(capabilityData)); + } + } + } + + _IOSCoreDeviceConnectionProperties? connectionProperties; + if (data['connectionProperties'] is Map) { + final Map connectionPropertiesData = data['connectionProperties']! as Map; + connectionProperties = _IOSCoreDeviceConnectionProperties.fromBetaJson( + connectionPropertiesData, + logger: logger, + ); + } + + IOSCoreDeviceProperties? deviceProperties; + if (data['deviceProperties'] is Map) { + final Map devicePropertiesData = data['deviceProperties']! as Map; + deviceProperties = IOSCoreDeviceProperties.fromBetaJson(devicePropertiesData); + } + + _IOSCoreDeviceHardwareProperties? hardwareProperties; + if (data['hardwareProperties'] is Map) { + final Map hardwarePropertiesData = data['hardwareProperties']! as Map; + hardwareProperties = _IOSCoreDeviceHardwareProperties.fromBetaJson( + hardwarePropertiesData, + logger: logger, + ); + } + + return IOSCoreDevice._( + capabilities: capabilitiesList, + connectionProperties: connectionProperties, + deviceProperties: deviceProperties, + hardwareProperties: hardwareProperties, + coreDeviceIdentifer: data['identifier']?.toString(), + visibilityClass: data['visibilityClass']?.toString(), + ); + } + + String? get udid => hardwareProperties?.udid; + + DeviceConnectionInterface? get connectionInterface { + final String? transportType = connectionProperties?.transportType; + if (transportType != null) { + if (transportType.toLowerCase() == 'localnetwork') { + return DeviceConnectionInterface.wireless; + } else if (transportType.toLowerCase() == 'wired') { + return DeviceConnectionInterface.attached; + } + } + return null; + } + + @visibleForTesting + final List<_IOSCoreDeviceCapability> capabilities; + + @visibleForTesting + final _IOSCoreDeviceConnectionProperties? connectionProperties; + + final IOSCoreDeviceProperties? deviceProperties; + + @visibleForTesting + final _IOSCoreDeviceHardwareProperties? hardwareProperties; + + final String? coreDeviceIdentifer; + final String? visibilityClass; +} + + +class _IOSCoreDeviceCapability { + _IOSCoreDeviceCapability._({ + required this.featureIdentifier, + required this.name, + }); + + /// Parse `capabilities` section of JSON from `devicectl list devices --json-output` + /// while it's in beta preview mode. + /// + /// Example: + /// "capabilities" : [ + /// { + /// "featureIdentifier" : "com.apple.coredevice.feature.spawnexecutable", + /// "name" : "Spawn Executable" + /// }, + /// { + /// "featureIdentifier" : "com.apple.coredevice.feature.launchapplication", + /// "name" : "Launch Application" + /// } + /// ] + factory _IOSCoreDeviceCapability.fromBetaJson(Map data) { + return _IOSCoreDeviceCapability._( + featureIdentifier: data['featureIdentifier']?.toString(), + name: data['name']?.toString(), + ); + } + + final String? featureIdentifier; + final String? name; +} + +class _IOSCoreDeviceConnectionProperties { + _IOSCoreDeviceConnectionProperties._({ + required this.authenticationType, + required this.isMobileDeviceOnly, + required this.lastConnectionDate, + required this.localHostnames, + required this.pairingState, + required this.potentialHostnames, + required this.transportType, + required this.tunnelIPAddress, + required this.tunnelState, + required this.tunnelTransportProtocol, + }); + + /// Parse `connectionProperties` section of JSON from `devicectl list devices --json-output` + /// while it's in beta preview mode. + /// + /// Example: + /// "connectionProperties" : { + /// "authenticationType" : "manualPairing", + /// "isMobileDeviceOnly" : false, + /// "lastConnectionDate" : "2023-06-15T15:29:00.082Z", + /// "localHostnames" : [ + /// "iPadName.coredevice.local", + /// "00001234-0001234A3C03401E.coredevice.local", + /// "12345BB5-AEDE-4A22-B653-6037262550DD.coredevice.local" + /// ], + /// "pairingState" : "paired", + /// "potentialHostnames" : [ + /// "00001234-0001234A3C03401E.coredevice.local", + /// "12345BB5-AEDE-4A22-B653-6037262550DD.coredevice.local" + /// ], + /// "transportType" : "wired", + /// "tunnelIPAddress" : "fdf1:23c4:cd56::1", + /// "tunnelState" : "connected", + /// "tunnelTransportProtocol" : "tcp" + /// } + factory _IOSCoreDeviceConnectionProperties.fromBetaJson( + Map data, { + required Logger logger, + }) { + List? localHostnames; + if (data['localHostnames'] is List) { + final List values = data['localHostnames']! as List; + try { + localHostnames = List.from(values); + } on TypeError { + logger.printTrace('Error parsing localHostnames value: $values'); + } + } + + List? potentialHostnames; + if (data['potentialHostnames'] is List) { + final List values = data['potentialHostnames']! as List; + try { + potentialHostnames = List.from(values); + } on TypeError { + logger.printTrace('Error parsing potentialHostnames value: $values'); + } + } + return _IOSCoreDeviceConnectionProperties._( + authenticationType: data['authenticationType']?.toString(), + isMobileDeviceOnly: data['isMobileDeviceOnly'] is bool? ? data['isMobileDeviceOnly'] as bool? : null, + lastConnectionDate: data['lastConnectionDate']?.toString(), + localHostnames: localHostnames, + pairingState: data['pairingState']?.toString(), + potentialHostnames: potentialHostnames, + transportType: data['transportType']?.toString(), + tunnelIPAddress: data['tunnelIPAddress']?.toString(), + tunnelState: data['tunnelState']?.toString(), + tunnelTransportProtocol: data['tunnelTransportProtocol']?.toString(), + ); + } + + final String? authenticationType; + final bool? isMobileDeviceOnly; + final String? lastConnectionDate; + final List? localHostnames; + final String? pairingState; + final List? potentialHostnames; + final String? transportType; + final String? tunnelIPAddress; + final String? tunnelState; + final String? tunnelTransportProtocol; +} + +@visibleForTesting +class IOSCoreDeviceProperties { + IOSCoreDeviceProperties._({ + required this.bootedFromSnapshot, + required this.bootedSnapshotName, + required this.bootState, + required this.ddiServicesAvailable, + required this.developerModeStatus, + required this.hasInternalOSBuild, + required this.name, + required this.osBuildUpdate, + required this.osVersionNumber, + required this.rootFileSystemIsWritable, + required this.screenViewingURL, + }); + + /// Parse `deviceProperties` section of JSON from `devicectl list devices --json-output` + /// while it's in beta preview mode. + /// + /// Example: + /// "deviceProperties" : { + /// "bootedFromSnapshot" : true, + /// "bootedSnapshotName" : "com.apple.os.update-B5336980824124F599FD39FE91016493A74331B09F475250BB010B276FE2439E3DE3537349A3A957D3FF2A4B623B4ECC", + /// "bootState" : "booted", + /// "ddiServicesAvailable" : true, + /// "developerModeStatus" : "enabled", + /// "hasInternalOSBuild" : false, + /// "name" : "iPadName", + /// "osBuildUpdate" : "21A5248v", + /// "osVersionNumber" : "17.0", + /// "rootFileSystemIsWritable" : false, + /// "screenViewingURL" : "coredevice-devices:/viewDeviceByUUID?uuid=123456BB5-AEDE-7A22-B890-1234567890DD" + /// } + factory IOSCoreDeviceProperties.fromBetaJson(Map data) { + return IOSCoreDeviceProperties._( + bootedFromSnapshot: data['bootedFromSnapshot'] is bool? ? data['bootedFromSnapshot'] as bool? : null, + bootedSnapshotName: data['bootedSnapshotName']?.toString(), + bootState: data['bootState']?.toString(), + ddiServicesAvailable: data['ddiServicesAvailable'] is bool? ? data['ddiServicesAvailable'] as bool? : null, + developerModeStatus: data['developerModeStatus']?.toString(), + hasInternalOSBuild: data['hasInternalOSBuild'] is bool? ? data['hasInternalOSBuild'] as bool? : null, + name: data['name']?.toString(), + osBuildUpdate: data['osBuildUpdate']?.toString(), + osVersionNumber: data['osVersionNumber']?.toString(), + rootFileSystemIsWritable: data['rootFileSystemIsWritable'] is bool? ? data['rootFileSystemIsWritable'] as bool? : null, + screenViewingURL: data['screenViewingURL']?.toString(), + ); + } + + final bool? bootedFromSnapshot; + final String? bootedSnapshotName; + final String? bootState; + final bool? ddiServicesAvailable; + final String? developerModeStatus; + final bool? hasInternalOSBuild; + final String? name; + final String? osBuildUpdate; + final String? osVersionNumber; + final bool? rootFileSystemIsWritable; + final String? screenViewingURL; +} + +class _IOSCoreDeviceHardwareProperties { + _IOSCoreDeviceHardwareProperties._({ + required this.cpuType, + required this.deviceType, + required this.ecid, + required this.hardwareModel, + required this.internalStorageCapacity, + required this.marketingName, + required this.platform, + required this.productType, + required this.serialNumber, + required this.supportedCPUTypes, + required this.supportedDeviceFamilies, + required this.thinningProductType, + required this.udid, + }); + + /// Parse `hardwareProperties` section of JSON from `devicectl list devices --json-output` + /// while it's in beta preview mode. + /// + /// Example: + /// "hardwareProperties" : { + /// "cpuType" : { + /// "name" : "arm64e", + /// "subType" : 2, + /// "type" : 16777228 + /// }, + /// "deviceType" : "iPad", + /// "ecid" : 12345678903408542, + /// "hardwareModel" : "J617AP", + /// "internalStorageCapacity" : 128000000000, + /// "marketingName" : "iPad Pro (11-inch) (4th generation)\"", + /// "platform" : "iOS", + /// "productType" : "iPad14,3", + /// "serialNumber" : "HC123DHCQV", + /// "supportedCPUTypes" : [ + /// { + /// "name" : "arm64e", + /// "subType" : 2, + /// "type" : 16777228 + /// }, + /// { + /// "name" : "arm64", + /// "subType" : 0, + /// "type" : 16777228 + /// } + /// ], + /// "supportedDeviceFamilies" : [ + /// 1, + /// 2 + /// ], + /// "thinningProductType" : "iPad14,3-A", + /// "udid" : "00001234-0001234A3C03401E" + /// } + factory _IOSCoreDeviceHardwareProperties.fromBetaJson( + Map data, { + required Logger logger, + }) { + _IOSCoreDeviceCPUType? cpuType; + if (data['cpuType'] is Map) { + cpuType = _IOSCoreDeviceCPUType.fromBetaJson(data['cpuType']! as Map); + } + + List<_IOSCoreDeviceCPUType>? supportedCPUTypes; + if (data['supportedCPUTypes'] is List) { + final List values = data['supportedCPUTypes']! as List; + final List<_IOSCoreDeviceCPUType> cpuTypes = <_IOSCoreDeviceCPUType>[]; + for (final Object? cpuTypeData in values) { + if (cpuTypeData is Map) { + cpuTypes.add(_IOSCoreDeviceCPUType.fromBetaJson(cpuTypeData)); + } + } + supportedCPUTypes = cpuTypes; + } + + List? supportedDeviceFamilies; + if (data['supportedDeviceFamilies'] is List) { + final List values = data['supportedDeviceFamilies']! as List; + try { + supportedDeviceFamilies = List.from(values); + } on TypeError { + logger.printTrace('Error parsing supportedDeviceFamilies value: $values'); + } + } + + return _IOSCoreDeviceHardwareProperties._( + cpuType: cpuType, + deviceType: data['deviceType']?.toString(), + ecid: data['ecid'] is int? ? data['ecid'] as int? : null, + hardwareModel: data['hardwareModel']?.toString(), + internalStorageCapacity: data['internalStorageCapacity'] is int? ? data['internalStorageCapacity'] as int? : null, + marketingName: data['marketingName']?.toString(), + platform: data['platform']?.toString(), + productType: data['productType']?.toString(), + serialNumber: data['serialNumber']?.toString(), + supportedCPUTypes: supportedCPUTypes, + supportedDeviceFamilies: supportedDeviceFamilies, + thinningProductType: data['thinningProductType']?.toString(), + udid: data['udid']?.toString(), + ); + } + + final _IOSCoreDeviceCPUType? cpuType; + final String? deviceType; + final int? ecid; + final String? hardwareModel; + final int? internalStorageCapacity; + final String? marketingName; + final String? platform; + final String? productType; + final String? serialNumber; + final List<_IOSCoreDeviceCPUType>? supportedCPUTypes; + final List? supportedDeviceFamilies; + final String? thinningProductType; + final String? udid; +} + +class _IOSCoreDeviceCPUType { + _IOSCoreDeviceCPUType._({ + this.name, + this.subType, + this.cpuType, + }); + + /// Parse `hardwareProperties.cpuType` and `hardwareProperties.supportedCPUTypes` + /// sections of JSON from `devicectl list devices --json-output` while it's in beta preview mode. + /// + /// Example: + /// "cpuType" : { + /// "name" : "arm64e", + /// "subType" : 2, + /// "type" : 16777228 + /// } + factory _IOSCoreDeviceCPUType.fromBetaJson(Map data) { + return _IOSCoreDeviceCPUType._( + name: data['name']?.toString(), + subType: data['subType'] is int? ? data['subType'] as int? : null, + cpuType: data['type'] is int? ? data['type'] as int? : null, + ); + } + + final String? name; + final int? subType; + final int? cpuType; +} + +@visibleForTesting +class IOSCoreDeviceInstalledApp { + IOSCoreDeviceInstalledApp._({ + required this.appClip, + required this.builtByDeveloper, + required this.bundleIdentifier, + required this.bundleVersion, + required this.defaultApp, + required this.hidden, + required this.internalApp, + required this.name, + required this.removable, + required this.url, + required this.version, + }); + + /// Parse JSON from `devicectl device info apps --json-output` while it's in + /// beta preview mode. + /// + /// Example: + /// { + /// "appClip" : false, + /// "builtByDeveloper" : true, + /// "bundleIdentifier" : "com.example.flutterApp", + /// "bundleVersion" : "1", + /// "defaultApp" : false, + /// "hidden" : false, + /// "internalApp" : false, + /// "name" : "Flutter App", + /// "removable" : true, + /// "url" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/", + /// "version" : "1.0.0" + /// } + factory IOSCoreDeviceInstalledApp.fromBetaJson(Map data) { + return IOSCoreDeviceInstalledApp._( + appClip: data['appClip'] is bool? ? data['appClip'] as bool? : null, + builtByDeveloper: data['builtByDeveloper'] is bool? ? data['builtByDeveloper'] as bool? : null, + bundleIdentifier: data['bundleIdentifier']?.toString(), + bundleVersion: data['bundleVersion']?.toString(), + defaultApp: data['defaultApp'] is bool? ? data['defaultApp'] as bool? : null, + hidden: data['hidden'] is bool? ? data['hidden'] as bool? : null, + internalApp: data['internalApp'] is bool? ? data['internalApp'] as bool? : null, + name: data['name']?.toString(), + removable: data['removable'] is bool? ? data['removable'] as bool? : null, + url: data['url']?.toString(), + version: data['version']?.toString(), + ); + } + + final bool? appClip; + final bool? builtByDeveloper; + final String? bundleIdentifier; + final String? bundleVersion; + final bool? defaultApp; + final bool? hidden; + final bool? internalApp; + final String? name; + final bool? removable; + final String? url; + final String? version; +} diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index 000529dad53..b9a02e8660f 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -15,6 +15,7 @@ import '../base/io.dart'; import '../base/logger.dart'; import '../base/os.dart'; import '../base/platform.dart'; +import '../base/process.dart'; import '../base/utils.dart'; import '../base/version.dart'; import '../build_info.dart'; @@ -28,10 +29,13 @@ import '../project.dart'; import '../protocol_discovery.dart'; import '../vmservice.dart'; import 'application_package.dart'; +import 'core_devices.dart'; import 'ios_deploy.dart'; import 'ios_workflow.dart'; import 'iproxy.dart'; import 'mac.dart'; +import 'xcode_debug.dart'; +import 'xcodeproj.dart'; class IOSDevices extends PollingDeviceDiscovery { IOSDevices({ @@ -263,16 +267,21 @@ class IOSDevice extends Device { required this.connectionInterface, required this.isConnected, required this.devModeEnabled, + required this.isCoreDevice, String? sdkVersion, required Platform platform, required IOSDeploy iosDeploy, required IMobileDevice iMobileDevice, + required IOSCoreDeviceControl coreDeviceControl, + required XcodeDebug xcodeDebug, required IProxy iProxy, required Logger logger, }) : _sdkVersion = sdkVersion, _iosDeploy = iosDeploy, _iMobileDevice = iMobileDevice, + _coreDeviceControl = coreDeviceControl, + _xcodeDebug = xcodeDebug, _iproxy = iProxy, _fileSystem = fileSystem, _logger = logger, @@ -294,6 +303,8 @@ class IOSDevice extends Device { final Logger _logger; final Platform _platform; final IMobileDevice _iMobileDevice; + final IOSCoreDeviceControl _coreDeviceControl; + final XcodeDebug _xcodeDebug; final IProxy _iproxy; Version? get sdkVersion { @@ -324,6 +335,10 @@ class IOSDevice extends Device { @override bool isConnected; + /// CoreDevice is a device connectivity stack introduced in Xcode 15. Devices + /// with iOS 17 or greater are CoreDevices. + final bool isCoreDevice; + final Map _logReaders = {}; DevicePortForwarder? _portForwarder; @@ -349,10 +364,17 @@ class IOSDevice extends Device { }) async { bool result; try { - result = await _iosDeploy.isAppInstalled( - bundleId: app.id, - deviceId: id, - ); + if (isCoreDevice) { + result = await _coreDeviceControl.isAppInstalled( + bundleId: app.id, + deviceId: id, + ); + } else { + result = await _iosDeploy.isAppInstalled( + bundleId: app.id, + deviceId: id, + ); + } } on ProcessException catch (e) { _logger.printError(e.message); return false; @@ -376,13 +398,20 @@ class IOSDevice extends Device { int installationResult; try { - installationResult = await _iosDeploy.installApp( - deviceId: id, - bundlePath: bundle.path, - appDeltaDirectory: app.appDeltaDirectory, - launchArguments: [], - interfaceType: connectionInterface, - ); + if (isCoreDevice) { + installationResult = await _coreDeviceControl.installApp( + deviceId: id, + bundlePath: bundle.path, + ) ? 0 : 1; + } else { + installationResult = await _iosDeploy.installApp( + deviceId: id, + bundlePath: bundle.path, + appDeltaDirectory: app.appDeltaDirectory, + launchArguments: [], + interfaceType: connectionInterface, + ); + } } on ProcessException catch (e) { _logger.printError(e.message); return false; @@ -404,10 +433,17 @@ class IOSDevice extends Device { }) async { int uninstallationResult; try { - uninstallationResult = await _iosDeploy.uninstallApp( - deviceId: id, - bundleId: app.id, - ); + if (isCoreDevice) { + uninstallationResult = await _coreDeviceControl.uninstallApp( + deviceId: id, + bundleId: app.id, + ) ? 0 : 1; + } else { + uninstallationResult = await _iosDeploy.uninstallApp( + deviceId: id, + bundleId: app.id, + ); + } } on ProcessException catch (e) { _logger.printError(e.message); return false; @@ -434,6 +470,7 @@ class IOSDevice extends Device { bool ipv6 = false, String? userIdentifier, @visibleForTesting Duration? discoveryTimeout, + @visibleForTesting ShutdownHooks? shutdownHooks, }) async { String? packageId; if (isWirelesslyConnected && @@ -441,6 +478,18 @@ class IOSDevice extends Device { debuggingOptions.disablePortPublication) { throwToolExit('Cannot start app on wirelessly tethered iOS device. Try running again with the --publish-port flag'); } + + // TODO(vashworth): Remove after Xcode 15 and iOS 17 are in CI (https://github.com/flutter/flutter/issues/132128) + // XcodeDebug workflow is used for CoreDevices (iOS 17+ and Xcode 15+). + // Force the use of XcodeDebug workflow in CI to test from older versions + // since devicelab has not yet been updated to iOS 17 and Xcode 15. + bool forceXcodeDebugWorkflow = false; + if (debuggingOptions.usingCISystem && + debuggingOptions.debuggingEnabled && + _platform.environment['FORCE_XCODE_DEBUG']?.toLowerCase() == 'true') { + forceXcodeDebugWorkflow = true; + } + if (!prebuiltApplication) { _logger.printTrace('Building ${package.name} for $id'); @@ -451,6 +500,7 @@ class IOSDevice extends Device { targetOverride: mainPath, activeArch: cpuArchitecture, deviceID: id, + isCoreDevice: isCoreDevice || forceXcodeDebugWorkflow, ); if (!buildResult.success) { _logger.printError('Could not build the precompiled application for the device.'); @@ -477,6 +527,7 @@ class IOSDevice extends Device { platformArgs, ipv6: ipv6, interfaceType: connectionInterface, + isCoreDevice: isCoreDevice, ); Status startAppStatus = _logger.startProgress( 'Installing and launching...', @@ -516,7 +567,16 @@ class IOSDevice extends Device { logger: _logger, ); } - if (iosDeployDebugger == null) { + + if (isCoreDevice || forceXcodeDebugWorkflow) { + installationResult = await _startAppOnCoreDevice( + debuggingOptions: debuggingOptions, + package: package, + launchArguments: launchArguments, + discoveryTimeout: discoveryTimeout, + shutdownHooks: shutdownHooks ?? globals.shutdownHooks, + ) ? 0 : 1; + } else if (iosDeployDebugger == null) { installationResult = await _iosDeploy.launchApp( deviceId: id, bundlePath: bundle.path, @@ -543,10 +603,26 @@ class IOSDevice extends Device { _logger.printTrace('Application launched on the device. Waiting for Dart VM Service url.'); - final int defaultTimeout = isWirelesslyConnected ? 45 : 30; + final int defaultTimeout; + if ((isCoreDevice || forceXcodeDebugWorkflow) && debuggingOptions.debuggingEnabled) { + // Core devices with debugging enabled takes longer because this + // includes time to install and launch the app on the device. + defaultTimeout = isWirelesslyConnected ? 75 : 60; + } else if (isWirelesslyConnected) { + defaultTimeout = 45; + } else { + defaultTimeout = 30; + } + final Timer timer = Timer(discoveryTimeout ?? Duration(seconds: defaultTimeout), () { _logger.printError('The Dart VM Service was not discovered after $defaultTimeout seconds. This is taking much longer than expected...'); - + if (isCoreDevice && debuggingOptions.debuggingEnabled) { + _logger.printError( + 'Open the Xcode window the project is opened in to ensure the app ' + 'is running. If the app is not running, try selecting "Product > Run" ' + 'to fix the problem.', + ); + } // If debugging with a wireless device and the timeout is reached, remind the // user to allow local network permissions. if (isWirelesslyConnected) { @@ -564,37 +640,71 @@ class IOSDevice extends Device { Uri? localUri; if (isWirelesslyConnected) { - // Wait for Dart VM Service to start up. - final Uri? serviceURL = await vmServiceDiscovery?.uri; - if (serviceURL == null) { - await iosDeployDebugger?.stopAndDumpBacktrace(); - await dispose(); - return LaunchResult.failed(); - } - - // 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 && vmServiceDiscovery != null) { + // When searching for the Dart VM url, search for it via ProtocolDiscovery + // (device logs) and mDNS simultaneously, since both can be flaky at times. + final Future vmUrlFromMDns = MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch( + packageId, + this, + usesIpv6: ipv6, + ); + final Future vmUrlFromLogs = vmServiceDiscovery.uri; + localUri = await Future.any( + >[vmUrlFromMDns, vmUrlFromLogs] + ); + } else { + localUri = await vmServiceDiscovery?.uri; + } } timer.cancel(); if (localUri == null) { @@ -613,6 +723,110 @@ class IOSDevice extends Device { } } + /// Starting with Xcode 15 and iOS 17, `ios-deploy` stopped working due to + /// the new CoreDevice connectivity stack. Previously, `ios-deploy` was used + /// to install the app, launch the app, and start `debugserver`. + /// Xcode 15 introduced a new command line tool called `devicectl` that + /// includes much of the functionality supplied by `ios-deploy`. However, + /// `devicectl` lacks the ability to start a `debugserver` and therefore `ptrace`, which are needed + /// for debug mode due to using a JIT Dart VM. + /// + /// Therefore, when starting an app on a CoreDevice, use `devicectl` when + /// debugging is not enabled. Otherwise, use Xcode automation. + Future _startAppOnCoreDevice({ + required DebuggingOptions debuggingOptions, + required IOSApp package, + required List launchArguments, + required ShutdownHooks shutdownHooks, + @visibleForTesting Duration? discoveryTimeout, + }) async { + if (!debuggingOptions.debuggingEnabled) { + // Release mode + + // Install app to device + final bool installSuccess = await _coreDeviceControl.installApp( + deviceId: id, + bundlePath: package.deviceBundlePath, + ); + if (!installSuccess) { + return installSuccess; + } + + // Launch app to device + final bool launchSuccess = await _coreDeviceControl.launchApp( + deviceId: id, + bundleId: package.id, + launchArguments: launchArguments, + ); + + return launchSuccess; + } else { + _logger.printStatus( + 'You may be prompted to give access to control Xcode. Flutter uses Xcode ' + 'to run your app. If access is not allowed, you can change this through ' + 'your Settings > Privacy & Security > Automation.', + ); + final int launchTimeout = isWirelesslyConnected ? 45 : 30; + final Timer timer = Timer(discoveryTimeout ?? Duration(seconds: launchTimeout), () { + _logger.printError( + 'Xcode is taking longer than expected to start debugging the app. ' + 'Ensure the project is opened in Xcode.', + ); + }); + + XcodeDebugProject debugProject; + + if (package is PrebuiltIOSApp) { + debugProject = await _xcodeDebug.createXcodeProjectWithCustomBundle( + package.deviceBundlePath, + templateRenderer: globals.templateRenderer, + verboseLogging: _logger.isVerbose, + ); + } else if (package is BuildableIOSApp) { + final IosProject project = package.project; + final XcodeProjectInfo? projectInfo = await project.projectInfo(); + if (projectInfo == null) { + globals.printError('Xcode project not found.'); + return false; + } + if (project.xcodeWorkspace == null) { + globals.printError('Unable to get Xcode workspace.'); + return false; + } + final String? scheme = projectInfo.schemeFor(debuggingOptions.buildInfo); + if (scheme == null) { + projectInfo.reportFlavorNotFoundAndExit(); + } + + debugProject = XcodeDebugProject( + scheme: scheme, + xcodeProject: project.xcodeProject, + xcodeWorkspace: project.xcodeWorkspace!, + verboseLogging: _logger.isVerbose, + ); + } else { + // This should not happen. Currently, only PrebuiltIOSApp and + // BuildableIOSApp extend from IOSApp. + _logger.printError('IOSApp type ${package.runtimeType} is not recognized.'); + return false; + } + + final bool debugSuccess = await _xcodeDebug.debugApp( + project: debugProject, + deviceId: id, + launchArguments:launchArguments, + ); + timer.cancel(); + + // Kill Xcode on shutdown when running from CI + if (debuggingOptions.usingCISystem) { + shutdownHooks.addShutdownHook(() => _xcodeDebug.exit(force: true)); + } + + return debugSuccess; + } + } + @override Future stopApp( ApplicationPackage? app, { @@ -623,6 +837,9 @@ class IOSDevice extends Device { if (deployDebugger != null && deployDebugger.debuggerAttached) { return deployDebugger.exit(); } + if (_xcodeDebug.debugStarted) { + return _xcodeDebug.exit(); + } return false; } @@ -669,7 +886,14 @@ class IOSDevice extends Device { void clearLogs() { } @override - bool get supportsScreenshot => _iMobileDevice.isInstalled; + bool get supportsScreenshot { + if (isCoreDevice) { + // `idevicescreenshot` stopped working with iOS 17 / Xcode 15 + // (https://github.com/flutter/flutter/issues/128598). + return false; + } + return _iMobileDevice.isInstalled; + } @override Future takeScreenshot(File outputFile) async { @@ -757,14 +981,18 @@ class IOSDeviceLogReader extends DeviceLogReader { this._majorSdkVersion, this._deviceId, this.name, + this._isWirelesslyConnected, + this._isCoreDevice, String appName, - bool usingCISystem, - ) : // Match for lines for the runner in syslog. + bool usingCISystem, { + bool forceXcodeDebug = false, + }) : // Match for lines for the runner in syslog. // // iOS 9 format: Runner[297] : // iOS 10 format: Runner(Flutter)[297] : _runnerLineRegex = RegExp(appName + r'(\(Flutter\))?\[[\d]+\] <[A-Za-z]+>: '), - _usingCISystem = usingCISystem; + _usingCISystem = usingCISystem, + _forceXcodeDebug = forceXcodeDebug; /// Create a new [IOSDeviceLogReader]. factory IOSDeviceLogReader.create({ @@ -779,8 +1007,11 @@ class IOSDeviceLogReader extends DeviceLogReader { device.majorSdkVersion, device.id, device.name, + device.isWirelesslyConnected, + device.isCoreDevice, appName, usingCISystem, + forceXcodeDebug: device._platform.environment['FORCE_XCODE_DEBUG']?.toLowerCase() == 'true', ); } @@ -790,6 +1021,8 @@ class IOSDeviceLogReader extends DeviceLogReader { bool useSyslog = true, bool usingCISystem = false, int? majorSdkVersion, + bool isWirelesslyConnected = false, + bool isCoreDevice = false, }) { final int sdkVersion; if (majorSdkVersion != null) { @@ -798,16 +1031,22 @@ class IOSDeviceLogReader extends DeviceLogReader { sdkVersion = useSyslog ? 12 : 13; } return IOSDeviceLogReader._( - iMobileDevice, sdkVersion, '1234', 'test', 'Runner', usingCISystem); + iMobileDevice, sdkVersion, '1234', 'test', isWirelesslyConnected, isCoreDevice, 'Runner', usingCISystem); } @override final String name; final int _majorSdkVersion; final String _deviceId; + final bool _isWirelesslyConnected; + final bool _isCoreDevice; final IMobileDevice _iMobileDevice; final bool _usingCISystem; + // TODO(vashworth): Remove after Xcode 15 and iOS 17 are in CI (https://github.com/flutter/flutter/issues/132128) + /// Whether XcodeDebug workflow is being forced. + final bool _forceXcodeDebug; + // Matches a syslog line from the runner. RegExp _runnerLineRegex; @@ -845,16 +1084,13 @@ class IOSDeviceLogReader extends DeviceLogReader { /// is true. final List _streamFlutterMessages = []; - /// 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. + /// 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, exclude logs with a `flutter:` prefix if they have + /// already been added to the stream. This is to prevent duplicates from + /// being printed. bool _excludeLog(String message, IOSDeviceLogSource source) { - if (!useBothLogDeviceReaders) { + if (!usingMultipleLoggingSources) { return false; } if (message.startsWith('flutter:')) { @@ -862,7 +1098,12 @@ class IOSDeviceLogReader extends DeviceLogReader { return true; } _streamFlutterMessages.add(message); - } else if (source == IOSDeviceLogSource.idevicesyslog) { + } else if (useIOSDeployLogging && source == IOSDeviceLogSource.idevicesyslog) { + // If using both `ios-deploy` and `idevicesyslog` simultaneously, exclude + // the message if its source is `idevicesyslog`. This is done because + //`ios-deploy` and `idevicesyslog` often have different prefixes, which + // makes duplicate matching difficult. Instead, exclude any non-flutter-prefixed + // `idevicesyslog` messages, which are not critical for CI tests. return true; } return false; @@ -887,12 +1128,114 @@ class IOSDeviceLogReader extends DeviceLogReader { static const int minimumUniversalLoggingSdkVersion = 13; + /// Use `idevicesyslog` to stream logs from the device when one of the + /// following criteria is met: + /// + /// 1) The device is a physically attached CoreDevice. + /// 2) The device has iOS 16 or greater and it's being debugged from CI. + /// 3) The device has iOS 12 or lower. + /// + /// 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. + @visibleForTesting + bool get useSyslogLogging { + // When forcing XcodeDebug workflow, use `idevicesyslog`. + if (_forceXcodeDebug) { + return true; + } + + // `ios-deploy` stopped working with iOS 17 / Xcode 15, so use `idevicesyslog` instead. + // However, `idevicesyslog` does not work with iOS 17 wireless devices. + if (_isCoreDevice && !_isWirelesslyConnected) { + return true; + } + + // 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 + if (_usingCISystem && _majorSdkVersion >= 16) { + return true; + } + if (_majorSdkVersion < minimumUniversalLoggingSdkVersion) { + return true; + } + return false; + } + + /// Use the Dart VM to stream logs from the device when one of the following + /// criteria is met: + /// + /// 1) The device is a CoreDevice and wirelessly connected. + /// 2) The device has iOS 13 or greater and [_iosDeployDebugger] is null or + /// the [_iosDeployDebugger] debugger is not attached. + /// + /// This value may change if [_iosDeployDebugger] changes. + @visibleForTesting + bool get useUnifiedLogging { + // Can't use Unified Logging if it's not going to listen to the Dart VM. + if (!_shouldListenForUnifiedLoggingEvents) { + return false; + } + + // `idevicesyslog` doesn't work on wireless devices, so use logs from Dart VM instead. + if (_isCoreDevice) { + return true; + } + + // Prefer the more complete logs from the attached debugger, if they are available. + if (_majorSdkVersion >= minimumUniversalLoggingSdkVersion && (_iosDeployDebugger == null || !_iosDeployDebugger!.debuggerAttached)) { + return true; + } + + return false; + } + + /// Determine whether to listen to the Dart VM for logging events. Returns + /// true when one of the following criteria is met: + /// + /// 1) The device is a CoreDevice and wirelessly connected. + /// 2) The device has iOS 13 or greater. + bool get _shouldListenForUnifiedLoggingEvents { + // `idevicesyslog` doesn't work on wireless devices, so use logs from Dart VM instead. + if (_isCoreDevice) { + return true; + } + + if (_majorSdkVersion >= minimumUniversalLoggingSdkVersion) { + return true; + } + + return false; + } + + + /// Use `ios-deploy` to stream logs from the device when the device is not a + /// CoreDevice and has iOS 13 or greater. + @visibleForTesting + bool get useIOSDeployLogging { + if (_majorSdkVersion < minimumUniversalLoggingSdkVersion || _isCoreDevice) { + return false; + } + return true; + } + + @visibleForTesting + /// Returns true when using multiple sources for streaming the device logs. + bool get usingMultipleLoggingSources { + final int numberOfSources = (useIOSDeployLogging ? 1 : 0) + (useSyslogLogging ? 1 : 0) + (useUnifiedLogging ? 1 : 0); + if (numberOfSources > 1) { + return true; + } + return false; + } + /// Listen to Dart VM for logs on iOS 13 or greater. /// /// Only send logs to stream if [_iosDeployDebugger] is null or /// the [_iosDeployDebugger] debugger is not attached. Future _listenToUnifiedLoggingEvents(FlutterVmService connectedVmService) async { - if (_majorSdkVersion < minimumUniversalLoggingSdkVersion) { + if (!_shouldListenForUnifiedLoggingEvents) { return; } try { @@ -909,7 +1252,7 @@ class IOSDeviceLogReader extends DeviceLogReader { } void logMessage(vm_service.Event event) { - if (_iosDeployDebugger != null && _iosDeployDebugger!.debuggerAttached) { + if (!useUnifiedLogging) { // Prefer the more complete logs from the attached debugger. return; } @@ -931,7 +1274,7 @@ class IOSDeviceLogReader extends DeviceLogReader { /// Send messages from ios-deploy debugger stream to device log reader stream. set debuggerStream(IOSDeployDebugger? debugger) { // Logging is gathered from syslog on iOS earlier than 13. - if (_majorSdkVersion < minimumUniversalLoggingSdkVersion) { + if (!useIOSDeployLogging) { return; } _iosDeployDebugger = debugger; @@ -954,22 +1297,10 @@ class IOSDeviceLogReader extends DeviceLogReader { // Strip off the logging metadata (leave the category), or just echo the line. String _debuggerLineHandler(String line) => _debuggerLoggingRegex.firstMatch(line)?.group(1) ?? line; - /// Use both logs from `idevicesyslog` and `ios-deploy` when debugging from CI system - /// since sometimes `ios-deploy` does not return the device logs: - /// https://github.com/flutter/flutter/issues/121231 - @visibleForTesting - bool get useBothLogDeviceReaders { - return _usingCISystem && _majorSdkVersion >= 16; - } - /// Start and listen to idevicesyslog to get device logs for iOS versions /// prior to 13 or if [useBothLogDeviceReaders] is true. void _listenToSysLog() { - // Syslog stopped working on iOS 13 (https://github.com/flutter/flutter/issues/41133). - // However, from at least iOS 16, it has began working again. It's unclear - // why it started working again so only use syslogs for iOS versions prior - // to 13 unless [useBothLogDeviceReaders] is true. - if (!useBothLogDeviceReaders && _majorSdkVersion >= minimumUniversalLoggingSdkVersion) { + if (!useSyslogLogging) { return; } _iMobileDevice.startLogger(_deviceId).then((Process process) { @@ -982,7 +1313,7 @@ class IOSDeviceLogReader extends DeviceLogReader { // When using both log readers, do not close the stream on exit. // This is to allow ios-deploy to be the source of authority to close // the stream. - if (useBothLogDeviceReaders && debuggerStream != null) { + if (useSyslogLogging && useIOSDeployLogging && debuggerStream != null) { return; } linesController.close(); diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart index 6a088c705cd..34c487e7ff6 100644 --- a/packages/flutter_tools/lib/src/ios/mac.dart +++ b/packages/flutter_tools/lib/src/ios/mac.dart @@ -132,6 +132,7 @@ Future buildXcodeProject({ DarwinArch? activeArch, bool codesign = true, String? deviceID, + bool isCoreDevice = false, bool configOnly = false, XcodeBuildAction buildAction = XcodeBuildAction.build, }) async { @@ -240,6 +241,7 @@ Future buildXcodeProject({ project: project, targetOverride: targetOverride, buildInfo: buildInfo, + usingCoreDevice: isCoreDevice, ); await processPodsIfNeeded(project.ios, getIosBuildDirectory(), buildInfo.mode); if (configOnly) { diff --git a/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart b/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart index 2ef75a209de..0e7c42b9f34 100644 --- a/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart +++ b/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart @@ -35,6 +35,7 @@ Future updateGeneratedXcodeProperties({ String? targetOverride, bool useMacOSConfig = false, String? buildDirOverride, + bool usingCoreDevice = false, }) async { final List xcodeBuildSettings = await _xcodeBuildSettingsLines( project: project, @@ -42,6 +43,7 @@ Future updateGeneratedXcodeProperties({ targetOverride: targetOverride, useMacOSConfig: useMacOSConfig, buildDirOverride: buildDirOverride, + usingCoreDevice: usingCoreDevice, ); _updateGeneratedXcodePropertiesFile( @@ -143,6 +145,7 @@ Future> _xcodeBuildSettingsLines({ String? targetOverride, bool useMacOSConfig = false, String? buildDirOverride, + bool usingCoreDevice = false, }) async { final List xcodeBuildSettings = []; @@ -170,6 +173,12 @@ Future> _xcodeBuildSettingsLines({ final String buildNumber = parsedBuildNumber(manifest: project.manifest, buildInfo: buildInfo) ?? '1'; xcodeBuildSettings.add('FLUTTER_BUILD_NUMBER=$buildNumber'); + // CoreDevices in debug and profile mode are launched, but not built, via Xcode. + // Set the BUILD_DIR so Xcode knows where to find the app bundle to launch. + if (usingCoreDevice && !buildInfo.isRelease) { + xcodeBuildSettings.add('BUILD_DIR=${globals.fs.path.absolute(getIosBuildDirectory())}'); + } + final LocalEngineInfo? localEngineInfo = globals.artifacts?.localEngineInfo; if (localEngineInfo != null) { final String engineOutPath = localEngineInfo.engineOutPath; diff --git a/packages/flutter_tools/lib/src/ios/xcode_debug.dart b/packages/flutter_tools/lib/src/ios/xcode_debug.dart new file mode 100644 index 00000000000..2708add258d --- /dev/null +++ b/packages/flutter_tools/lib/src/ios/xcode_debug.dart @@ -0,0 +1,479 @@ +// 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/file_system.dart'; +import '../base/io.dart'; +import '../base/logger.dart'; +import '../base/process.dart'; +import '../base/template.dart'; +import '../convert.dart'; +import '../macos/xcode.dart'; +import '../template.dart'; + +/// A class to handle interacting with Xcode via OSA (Open Scripting Architecture) +/// Scripting to debug Flutter applications. +class XcodeDebug { + XcodeDebug({ + required Logger logger, + required ProcessManager processManager, + required Xcode xcode, + required FileSystem fileSystem, + }) : _logger = logger, + _processUtils = ProcessUtils(logger: logger, processManager: processManager), + _xcode = xcode, + _fileSystem = fileSystem; + + final ProcessUtils _processUtils; + final Logger _logger; + final Xcode _xcode; + final FileSystem _fileSystem; + + /// Process to start Xcode's debug action. + @visibleForTesting + Process? startDebugActionProcess; + + /// Information about the project that is currently being debugged. + @visibleForTesting + XcodeDebugProject? currentDebuggingProject; + + /// Whether the debug action has been started. + bool get debugStarted => currentDebuggingProject != null; + + /// Install, launch, and start a debug session for app through Xcode interface, + /// automated by OSA scripting. First checks if the project is opened in + /// Xcode. If it isn't, open it with the `open` command. + /// + /// The OSA script waits until the project is opened and the debug action + /// has started. It does not wait for the app to install, launch, or start + /// the debug session. + Future debugApp({ + required XcodeDebugProject project, + required String deviceId, + required List launchArguments, + }) async { + + // If project is not already opened in Xcode, open it. + if (!await _isProjectOpenInXcode(project: project)) { + final bool openResult = await _openProjectInXcode(xcodeWorkspace: project.xcodeWorkspace); + if (!openResult) { + return openResult; + } + } + + currentDebuggingProject = project; + StreamSubscription? stdoutSubscription; + StreamSubscription? stderrSubscription; + try { + startDebugActionProcess = await _processUtils.start( + [ + ..._xcode.xcrunCommand(), + 'osascript', + '-l', + 'JavaScript', + _xcode.xcodeAutomationScriptPath, + 'debug', + '--xcode-path', + _xcode.xcodeAppPath, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--device-id', + deviceId, + '--scheme', + project.scheme, + '--skip-building', + '--launch-args', + json.encode(launchArguments), + if (project.verboseLogging) '--verbose', + ], + ); + + final StringBuffer stdoutBuffer = StringBuffer(); + stdoutSubscription = startDebugActionProcess!.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + _logger.printTrace(line); + stdoutBuffer.write(line); + }); + + final StringBuffer stderrBuffer = StringBuffer(); + bool permissionWarningPrinted = false; + // console.log from the script are found in the stderr + stderrSubscription = startDebugActionProcess!.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + _logger.printTrace('stderr: $line'); + stderrBuffer.write(line); + + // This error may occur if Xcode automation has not been allowed. + // Example: Failed to get workspace: Error: An error occurred. + if (!permissionWarningPrinted && line.contains('Failed to get workspace') && line.contains('An error occurred')) { + _logger.printError( + 'There was an error finding the project in Xcode. Ensure permission ' + 'has been given to control Xcode in Settings > Privacy & Security > Automation.', + ); + permissionWarningPrinted = true; + } + }); + + final int exitCode = await startDebugActionProcess!.exitCode.whenComplete(() async { + await stdoutSubscription?.cancel(); + await stderrSubscription?.cancel(); + startDebugActionProcess = null; + }); + + if (exitCode != 0) { + _logger.printError('Error executing osascript: $exitCode\n$stderrBuffer'); + return false; + } + + final XcodeAutomationScriptResponse? response = parseScriptResponse( + stdoutBuffer.toString(), + ); + if (response == null) { + return false; + } + if (response.status == false) { + _logger.printError('Error starting debug session in Xcode: ${response.errorMessage}'); + return false; + } + if (response.debugResult == null) { + _logger.printError('Unable to get debug results from response: $stdoutBuffer'); + return false; + } + if (response.debugResult?.status != 'running') { + _logger.printError( + 'Unexpected debug results: \n' + ' Status: ${response.debugResult?.status}\n' + ' Completed: ${response.debugResult?.completed}\n' + ' Error Message: ${response.debugResult?.errorMessage}\n' + ); + return false; + } + return true; + } on ProcessException catch (exception) { + _logger.printError('Error executing osascript: $exitCode\n$exception'); + await stdoutSubscription?.cancel(); + await stderrSubscription?.cancel(); + startDebugActionProcess = null; + + return false; + } + } + + /// Kills [startDebugActionProcess] if it's still running. If [force] is true, it + /// will kill all Xcode app processes. Otherwise, it will stop the debug + /// session in Xcode. If the project is temporary, it will close the Xcode + /// window of the project and then delete the project. + Future exit({ + bool force = false, + @visibleForTesting + bool skipDelay = false, + }) async { + final bool success = (startDebugActionProcess == null) || startDebugActionProcess!.kill(); + + if (force) { + await _forceExitXcode(); + if (currentDebuggingProject != null) { + final XcodeDebugProject project = currentDebuggingProject!; + if (project.isTemporaryProject) { + project.xcodeProject.parent.deleteSync(recursive: true); + } + currentDebuggingProject = null; + } + } + + if (currentDebuggingProject != null) { + final XcodeDebugProject project = currentDebuggingProject!; + await stopDebuggingApp( + project: project, + closeXcode: project.isTemporaryProject, + ); + + if (project.isTemporaryProject) { + // Wait a couple seconds before deleting the project. If project is + // still opened in Xcode and it's deleted, it will prompt the user to + // restore it. + if (!skipDelay) { + await Future.delayed(const Duration(seconds: 2)); + } + + try { + project.xcodeProject.parent.deleteSync(recursive: true); + } on FileSystemException { + _logger.printError('Failed to delete temporary Xcode project: ${project.xcodeProject.parent.path}'); + } + } + currentDebuggingProject = null; + } + + return success; + } + + /// Kill all opened Xcode applications. + Future _forceExitXcode() async { + final RunResult result = await _processUtils.run( + [ + 'killall', + '-9', + 'Xcode', + ], + ); + + if (result.exitCode != 0) { + _logger.printError('Error killing Xcode: ${result.exitCode}\n${result.stderr}'); + return false; + } + return true; + } + + Future _isProjectOpenInXcode({ + required XcodeDebugProject project, + }) async { + + final RunResult result = await _processUtils.run( + [ + ..._xcode.xcrunCommand(), + 'osascript', + '-l', + 'JavaScript', + _xcode.xcodeAutomationScriptPath, + 'check-workspace-opened', + '--xcode-path', + _xcode.xcodeAppPath, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + if (project.verboseLogging) '--verbose', + ], + ); + + if (result.exitCode != 0) { + _logger.printError('Error executing osascript: ${result.exitCode}\n${result.stderr}'); + return false; + } + + final XcodeAutomationScriptResponse? response = parseScriptResponse(result.stdout); + if (response == null) { + return false; + } + if (response.status == false) { + _logger.printTrace('Error checking if project opened in Xcode: ${response.errorMessage}'); + return false; + } + return true; + } + + @visibleForTesting + XcodeAutomationScriptResponse? parseScriptResponse(String results) { + try { + final Object decodeResult = json.decode(results) as Object; + if (decodeResult is Map) { + final XcodeAutomationScriptResponse response = XcodeAutomationScriptResponse.fromJson(decodeResult); + // Status should always be found + if (response.status != null) { + return response; + } + } + _logger.printError('osascript returned unexpected JSON response: $results'); + return null; + } on FormatException { + _logger.printError('osascript returned non-JSON response: $results'); + return null; + } + } + + Future _openProjectInXcode({ + required Directory xcodeWorkspace, + }) async { + try { + await _processUtils.run( + [ + 'open', + '-a', + _xcode.xcodeAppPath, + '-g', // Do not bring the application to the foreground. + '-j', // Launches the app hidden. + xcodeWorkspace.path + ], + throwOnError: true, + ); + return true; + } on ProcessException catch (error, stackTrace) { + _logger.printError('$error', stackTrace: stackTrace); + } + return false; + } + + /// Using OSA Scripting, stop the debug session in Xcode. + /// + /// If [closeXcode] is true, it will close the Xcode window that has the + /// project opened. If [promptToSaveOnClose] is true, it will ask the user if + /// they want to save any changes before it closes. + Future stopDebuggingApp({ + required XcodeDebugProject project, + bool closeXcode = false, + bool promptToSaveOnClose = false, + }) async { + final RunResult result = await _processUtils.run( + [ + ..._xcode.xcrunCommand(), + 'osascript', + '-l', + 'JavaScript', + _xcode.xcodeAutomationScriptPath, + 'stop', + '--xcode-path', + _xcode.xcodeAppPath, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + if (closeXcode) '--close-window', + if (promptToSaveOnClose) '--prompt-to-save', + if (project.verboseLogging) '--verbose', + ], + ); + + if (result.exitCode != 0) { + _logger.printError('Error executing osascript: ${result.exitCode}\n${result.stderr}'); + return false; + } + + final XcodeAutomationScriptResponse? response = parseScriptResponse(result.stdout); + if (response == null) { + return false; + } + if (response.status == false) { + _logger.printError('Error stopping app in Xcode: ${response.errorMessage}'); + return false; + } + return true; + } + + /// Create a temporary empty Xcode project with the application bundle + /// location explicitly set. + Future createXcodeProjectWithCustomBundle( + String deviceBundlePath, { + required TemplateRenderer templateRenderer, + @visibleForTesting + Directory? projectDestination, + bool verboseLogging = false, + }) async { + final Directory tempXcodeProject = projectDestination ?? _fileSystem.systemTempDirectory.createTempSync('flutter_empty_xcode.'); + + final Template template = await Template.fromName( + _fileSystem.path.join('xcode', 'ios', 'custom_application_bundle'), + fileSystem: _fileSystem, + templateManifest: null, + logger: _logger, + templateRenderer: templateRenderer, + ); + + template.render( + tempXcodeProject, + { + 'applicationBundlePath': deviceBundlePath + }, + printStatusWhenWriting: false, + ); + + return XcodeDebugProject( + scheme: 'Runner', + xcodeProject: tempXcodeProject.childDirectory('Runner.xcodeproj'), + xcodeWorkspace: tempXcodeProject.childDirectory('Runner.xcworkspace'), + isTemporaryProject: true, + verboseLogging: verboseLogging, + ); + } +} + +@visibleForTesting +class XcodeAutomationScriptResponse { + XcodeAutomationScriptResponse._({ + this.status, + this.errorMessage, + this.debugResult, + }); + + factory XcodeAutomationScriptResponse.fromJson(Map data) { + XcodeAutomationScriptDebugResult? debugResult; + if (data['debugResult'] != null && data['debugResult'] is Map) { + debugResult = XcodeAutomationScriptDebugResult.fromJson( + data['debugResult']! as Map, + ); + } + return XcodeAutomationScriptResponse._( + status: data['status'] is bool? ? data['status'] as bool? : null, + errorMessage: data['errorMessage']?.toString(), + debugResult: debugResult, + ); + } + + final bool? status; + final String? errorMessage; + final XcodeAutomationScriptDebugResult? debugResult; +} + +@visibleForTesting +class XcodeAutomationScriptDebugResult { + XcodeAutomationScriptDebugResult._({ + required this.completed, + required this.status, + required this.errorMessage, + }); + + factory XcodeAutomationScriptDebugResult.fromJson(Map data) { + return XcodeAutomationScriptDebugResult._( + completed: data['completed'] is bool? ? data['completed'] as bool? : null, + status: data['status']?.toString(), + errorMessage: data['errorMessage']?.toString(), + ); + } + + /// Whether this scheme action has completed (sucessfully or otherwise). Will + /// be false if still running. + final bool? completed; + + /// The status of the debug action. Potential statuses include: + /// `not yet started`, `‌running`, `‌cancelled`, `‌failed`, `‌error occurred`, + /// and `‌succeeded`. + /// + /// Only the status of `‌running` indicates the debug action has started successfully. + /// For example, `‌succeeded` often does not indicate success as if the action fails, + /// it will sometimes return `‌succeeded`. + final String? status; + + /// When [status] is `‌error occurred`, an error message is provided. + /// Otherwise, this will be null. + final String? errorMessage; +} + +class XcodeDebugProject { + XcodeDebugProject({ + required this.scheme, + required this.xcodeWorkspace, + required this.xcodeProject, + this.isTemporaryProject = false, + this.verboseLogging = false, + }); + + final String scheme; + final Directory xcodeWorkspace; + final Directory xcodeProject; + final bool isTemporaryProject; + + /// When [verboseLogging] is true, the xcode_debug.js script will log + /// additional information via console.log, which is sent to stderr. + final bool verboseLogging; +} diff --git a/packages/flutter_tools/lib/src/macos/xcdevice.dart b/packages/flutter_tools/lib/src/macos/xcdevice.dart index c53fdcaf926..a2c66eea776 100644 --- a/packages/flutter_tools/lib/src/macos/xcdevice.dart +++ b/packages/flutter_tools/lib/src/macos/xcdevice.dart @@ -8,6 +8,7 @@ import 'package:meta/meta.dart'; import 'package:process/process.dart'; import '../artifacts.dart'; +import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; import '../base/platform.dart'; @@ -18,10 +19,12 @@ import '../cache.dart'; import '../convert.dart'; import '../device.dart'; import '../globals.dart' as globals; +import '../ios/core_devices.dart'; import '../ios/devices.dart'; import '../ios/ios_deploy.dart'; import '../ios/iproxy.dart'; import '../ios/mac.dart'; +import '../ios/xcode_debug.dart'; import '../reporting/reporting.dart'; import 'xcode.dart'; @@ -65,6 +68,10 @@ class XCDevice { required Xcode xcode, required Platform platform, required IProxy iproxy, + required FileSystem fileSystem, + @visibleForTesting + IOSCoreDeviceControl? coreDeviceControl, + XcodeDebug? xcodeDebug, }) : _processUtils = ProcessUtils(logger: logger, processManager: processManager), _logger = logger, _iMobileDevice = IMobileDevice( @@ -80,6 +87,18 @@ class XCDevice { platform: platform, processManager: processManager, ), + _coreDeviceControl = coreDeviceControl ?? IOSCoreDeviceControl( + logger: logger, + processManager: processManager, + xcode: xcode, + fileSystem: fileSystem, + ), + _xcodeDebug = xcodeDebug ?? XcodeDebug( + logger: logger, + processManager: processManager, + xcode: xcode, + fileSystem: fileSystem, + ), _iProxy = iproxy, _xcode = xcode { @@ -99,6 +118,8 @@ class XCDevice { final IOSDeploy _iosDeploy; final Xcode _xcode; final IProxy _iProxy; + final IOSCoreDeviceControl _coreDeviceControl; + final XcodeDebug _xcodeDebug; List? _cachedListResults; @@ -457,6 +478,17 @@ class XCDevice { return const []; } + final Map coreDeviceMap = {}; + if (_xcode.isDevicectlInstalled) { + final List coreDevices = await _coreDeviceControl.getCoreDevices(); + for (final IOSCoreDevice device in coreDevices) { + if (device.udid == null) { + continue; + } + coreDeviceMap[device.udid!] = device; + } + } + // [ // { // "simulator" : true, @@ -565,11 +597,27 @@ class XCDevice { } } + DeviceConnectionInterface connectionInterface = _interfaceType(device); + + // CoreDevices (devices with iOS 17 and greater) no longer reflect the + // correct connection interface or developer mode status in `xcdevice`. + // Use `devicectl` to get that information for CoreDevices. + final IOSCoreDevice? coreDevice = coreDeviceMap[identifier]; + if (coreDevice != null) { + if (coreDevice.connectionInterface != null) { + connectionInterface = coreDevice.connectionInterface!; + } + + if (coreDevice.deviceProperties?.developerModeStatus != 'enabled') { + devModeEnabled = false; + } + } + deviceMap[identifier] = IOSDevice( identifier, name: name, cpuArchitecture: _cpuArchitecture(device), - connectionInterface: _interfaceType(device), + connectionInterface: connectionInterface, isConnected: isConnected, sdkVersion: sdkVersionString, iProxy: _iProxy, @@ -577,8 +625,11 @@ class XCDevice { logger: _logger, iosDeploy: _iosDeploy, iMobileDevice: _iMobileDevice, + coreDeviceControl: _coreDeviceControl, + xcodeDebug: _xcodeDebug, platform: globals.platform, devModeEnabled: devModeEnabled, + isCoreDevice: coreDevice != null, ); } } diff --git a/packages/flutter_tools/lib/src/macos/xcode.dart b/packages/flutter_tools/lib/src/macos/xcode.dart index 4784013da8e..540f08d5f3b 100644 --- a/packages/flutter_tools/lib/src/macos/xcode.dart +++ b/packages/flutter_tools/lib/src/macos/xcode.dart @@ -14,8 +14,10 @@ import '../base/io.dart'; import '../base/logger.dart'; import '../base/platform.dart'; import '../base/process.dart'; +import '../base/user_messages.dart'; import '../base/version.dart'; import '../build_info.dart'; +import '../cache.dart'; import '../ios/xcodeproj.dart'; Version get xcodeRequiredVersion => Version(14, null, null); @@ -44,9 +46,13 @@ class Xcode { required Logger logger, required FileSystem fileSystem, required XcodeProjectInterpreter xcodeProjectInterpreter, + required UserMessages userMessages, + String? flutterRoot, }) : _platform = platform, _fileSystem = fileSystem, _xcodeProjectInterpreter = xcodeProjectInterpreter, + _userMessage = userMessages, + _flutterRoot = flutterRoot, _processUtils = ProcessUtils(logger: logger, processManager: processManager), _logger = logger; @@ -61,6 +67,7 @@ class Xcode { XcodeProjectInterpreter? xcodeProjectInterpreter, Platform? platform, FileSystem? fileSystem, + String? flutterRoot, Logger? logger, }) { platform ??= FakePlatform( @@ -72,6 +79,8 @@ class Xcode { platform: platform, processManager: processManager, fileSystem: fileSystem ?? MemoryFileSystem.test(), + userMessages: UserMessages(), + flutterRoot: flutterRoot, logger: logger, xcodeProjectInterpreter: xcodeProjectInterpreter ?? XcodeProjectInterpreter.test(processManager: processManager), ); @@ -81,6 +90,8 @@ class Xcode { final ProcessUtils _processUtils; final FileSystem _fileSystem; final XcodeProjectInterpreter _xcodeProjectInterpreter; + final UserMessages _userMessage; + final String? _flutterRoot; final Logger _logger; bool get isInstalledAndMeetsVersionCheck => _platform.isMacOS && isInstalled && isRequiredVersionSatisfactory; @@ -101,6 +112,38 @@ class Xcode { return _xcodeSelectPath; } + String get xcodeAppPath { + // If the Xcode Select Path is /Applications/Xcode.app/Contents/Developer, + // the path to Xcode App is /Applications/Xcode.app + + final String? pathToXcode = xcodeSelectPath; + if (pathToXcode == null || pathToXcode.isEmpty) { + throwToolExit(_userMessage.xcodeMissing); + } + final int index = pathToXcode.indexOf('.app'); + if (index == -1) { + throwToolExit(_userMessage.xcodeMissing); + } + return pathToXcode.substring(0, index + 4); + } + + /// Path to script to automate debugging through Xcode. Used in xcode_debug.dart. + /// Located in this file to make it easily overrideable in google3. + String get xcodeAutomationScriptPath { + final String flutterRoot = _flutterRoot ?? Cache.flutterRoot!; + final String flutterToolsAbsolutePath = _fileSystem.path.join( + flutterRoot, + 'packages', + 'flutter_tools', + ); + + final String filePath = '$flutterToolsAbsolutePath/bin/xcode_debug.js'; + if (!_fileSystem.file(filePath).existsSync()) { + throwToolExit('Unable to find Xcode automation script at $filePath'); + } + return filePath; + } + bool get isInstalled => _xcodeProjectInterpreter.isInstalled; Version? get currentVersion => _xcodeProjectInterpreter.version; @@ -150,6 +193,28 @@ class Xcode { return _isSimctlInstalled ?? false; } + bool? _isDevicectlInstalled; + + /// Verifies that `devicectl` is installed by checking Xcode version and trying + /// to run it. `devicectl` is made available in Xcode 15. + bool get isDevicectlInstalled { + if (_isDevicectlInstalled == null) { + try { + if (currentVersion == null || currentVersion!.major < 15) { + _isDevicectlInstalled = false; + return _isDevicectlInstalled!; + } + final RunResult result = _processUtils.runSync( + [...xcrunCommand(), 'devicectl', '--version'], + ); + _isDevicectlInstalled = result.exitCode == 0; + } on ProcessException { + _isDevicectlInstalled = false; + } + } + return _isDevicectlInstalled ?? false; + } + bool get isRequiredVersionSatisfactory { final Version? version = currentVersion; if (version == null) { diff --git a/packages/flutter_tools/lib/src/mdns_discovery.dart b/packages/flutter_tools/lib/src/mdns_discovery.dart index 82196118afc..da6527596ca 100644 --- a/packages/flutter_tools/lib/src/mdns_discovery.dart +++ b/packages/flutter_tools/lib/src/mdns_discovery.dart @@ -130,9 +130,9 @@ class MDnsVmServiceDiscovery { /// The [deviceVmservicePort] parameter must be set to specify which port /// to find. /// - /// [applicationId] and [deviceVmservicePort] are required for launch so that - /// if multiple flutter apps are running on different devices, it will - /// only match with the device running the desired app. + /// [applicationId] and either [deviceVmservicePort] or [deviceName] are + /// required for launch so that if multiple flutter apps are running on + /// different devices, it will only match with the device running the desired app. /// /// The [useDeviceIPAsHost] parameter flags whether to get the device IP /// and the [ipv6] parameter flags whether to get an iPv6 address @@ -141,21 +141,27 @@ class MDnsVmServiceDiscovery { /// The [timeout] parameter determines how long to continue to wait for /// services to become active. /// - /// If a Dart VM Service matching the [applicationId] and [deviceVmservicePort] - /// cannot be found after the [timeout], it will call [throwToolExit]. + /// If a Dart VM Service matching the [applicationId] and + /// [deviceVmservicePort]/[deviceName] cannot be found before the [timeout] + /// is reached, it will call [throwToolExit]. @visibleForTesting Future queryForLaunch({ required String applicationId, - required int deviceVmservicePort, + int? deviceVmservicePort, + String? deviceName, bool ipv6 = false, bool useDeviceIPAsHost = false, Duration timeout = const Duration(minutes: 10), }) async { - // Query for a specific application and device port. + // Either the device port or the device name must be provided. + assert(deviceVmservicePort != null || deviceName != null); + + // Query for a specific application matching on either device port or device name. return firstMatchingVmService( _client, applicationId: applicationId, deviceVmservicePort: deviceVmservicePort, + deviceName: deviceName, ipv6: ipv6, useDeviceIPAsHost: useDeviceIPAsHost, timeout: timeout, @@ -170,6 +176,7 @@ class MDnsVmServiceDiscovery { MDnsClient client, { String? applicationId, int? deviceVmservicePort, + String? deviceName, bool ipv6 = false, bool useDeviceIPAsHost = false, Duration timeout = const Duration(minutes: 10), @@ -178,6 +185,7 @@ class MDnsVmServiceDiscovery { client, applicationId: applicationId, deviceVmservicePort: deviceVmservicePort, + deviceName: deviceName, ipv6: ipv6, useDeviceIPAsHost: useDeviceIPAsHost, timeout: timeout, @@ -193,6 +201,7 @@ class MDnsVmServiceDiscovery { MDnsClient client, { String? applicationId, int? deviceVmservicePort, + String? deviceName, bool ipv6 = false, bool useDeviceIPAsHost = false, required Duration timeout, @@ -263,6 +272,11 @@ class MDnsVmServiceDiscovery { continue; } + // If deviceName is set, only use records that match it + if (deviceName != null && !deviceNameMatchesTargetName(deviceName, srvRecord.target)) { + continue; + } + // Get the IP address of the device if using the IP as the host. InternetAddress? ipAddress; if (useDeviceIPAsHost) { @@ -332,6 +346,15 @@ class MDnsVmServiceDiscovery { } } + @visibleForTesting + bool deviceNameMatchesTargetName(String deviceName, String targetName) { + // Remove `.local` from the name along with any non-word, non-digit characters. + final RegExp cleanedNameRegex = RegExp(r'\.local|\W'); + final String cleanedDeviceName = deviceName.trim().toLowerCase().replaceAll(cleanedNameRegex, ''); + final String cleanedTargetName = targetName.toLowerCase().replaceAll(cleanedNameRegex, ''); + return cleanedDeviceName == cleanedTargetName; + } + String _getAuthCode(String txtRecord) { const String authCodePrefix = 'authCode='; final Iterable matchingRecords = @@ -354,7 +377,7 @@ class MDnsVmServiceDiscovery { /// When [useDeviceIPAsHost] is true, it will use the device's IP as the /// host and will not forward the port. /// - /// Differs from `getVMServiceUriForLaunch` because it can search for any available Dart VM Service. + /// Differs from [getVMServiceUriForLaunch] because it can search for any available Dart VM Service. /// Since [applicationId] and [deviceVmservicePort] are optional, it can either look for any service /// or a specific service matching [applicationId]/[deviceVmservicePort]. /// It may find more than one service, which will throw an error listing the found services. @@ -391,20 +414,22 @@ class MDnsVmServiceDiscovery { /// When [useDeviceIPAsHost] is true, it will use the device's IP as the /// host and will not forward the port. /// - /// Differs from `getVMServiceUriForAttach` because it only searches for a specific service. - /// This is enforced by [applicationId] and [deviceVmservicePort] being required. + /// Differs from [getVMServiceUriForAttach] because it only searches for a specific service. + /// This is enforced by [applicationId] being required and using either the + /// [deviceVmservicePort] or the [device]'s name to query. Future getVMServiceUriForLaunch( String applicationId, Device device, { bool usesIpv6 = false, int? hostVmservicePort, - required int deviceVmservicePort, + int? deviceVmservicePort, bool useDeviceIPAsHost = false, Duration timeout = const Duration(minutes: 10), }) async { final MDnsVmServiceDiscoveryResult? result = await queryForLaunch( applicationId: applicationId, deviceVmservicePort: deviceVmservicePort, + deviceName: deviceVmservicePort == null ? device.name : null, ipv6: usesIpv6, useDeviceIPAsHost: useDeviceIPAsHost, timeout: timeout, diff --git a/packages/flutter_tools/templates/template_manifest.json b/packages/flutter_tools/templates/template_manifest.json index d0902d5edc2..3729d8909fa 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 00000000000..2b2b69707db --- /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 00000000000..8f544ef77eb --- /dev/null +++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.pbxproj @@ -0,0 +1,297 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXFileReference section */ + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 97C146EF1CF9000F007C117D /* Products */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/contents.xcworkspacedata b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000000..919434a6254 --- /dev/null +++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000000..18d981003d6 --- /dev/null +++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000000..f9b0d7c5ea1 --- /dev/null +++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/xcshareddata/xcschemes/Runner.xcscheme.tmpl b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/xcshareddata/xcschemes/Runner.xcscheme.tmpl new file mode 100644 index 00000000000..bcca935dea1 --- /dev/null +++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcodeproj.tmpl/xcshareddata/xcschemes/Runner.xcscheme.tmpl @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/contents.xcworkspacedata b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/contents.xcworkspacedata new file mode 100644 index 00000000000..1d526a16ed0 --- /dev/null +++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/xcshareddata/IDEWorkspaceChecks.plist b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000000..18d981003d6 --- /dev/null +++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/xcshareddata/WorkspaceSettings.xcsettings b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000000..f9b0d7c5ea1 --- /dev/null +++ b/packages/flutter_tools/templates/xcode/ios/custom_application_bundle/Runner.xcworkspace.tmpl/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/flutter_tools/test/general.shard/device_test.dart b/packages/flutter_tools/test/general.shard/device_test.dart index fab87b8f710..14e3dbb6bfe 100644 --- a/packages/flutter_tools/test/general.shard/device_test.dart +++ b/packages/flutter_tools/test/general.shard/device_test.dart @@ -884,6 +884,26 @@ void main() { ); }); + testWithoutContext('Get launch arguments for physical CoreDevice with debugging enabled with no launch arguments', () { + final DebuggingOptions original = DebuggingOptions.enabled( + BuildInfo.debug, + ); + + final List launchArguments = original.getIOSLaunchArguments( + EnvironmentType.physical, + null, + {}, + isCoreDevice: true, + ); + + expect( + launchArguments.join(' '), + [ + '--enable-dart-profiling', + ].join(' '), + ); + }); + testWithoutContext('Get launch arguments for physical device with iPv4 network connection', () { final DebuggingOptions original = DebuggingOptions.enabled( BuildInfo.debug, diff --git a/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart b/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart new file mode 100644 index 00000000000..d820e1a13bc --- /dev/null +++ b/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart @@ -0,0 +1,1949 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:file/memory.dart'; +import 'package:file_testing/file_testing.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/version.dart'; +import 'package:flutter_tools/src/ios/core_devices.dart'; +import 'package:flutter_tools/src/ios/xcodeproj.dart'; +import 'package:flutter_tools/src/macos/xcode.dart'; + +import '../../src/common.dart'; +import '../../src/fake_process_manager.dart'; + +void main() { + late MemoryFileSystem fileSystem; + + setUp(() { + fileSystem = MemoryFileSystem.test(); + }); + + group('Xcode prior to Core Device Control/Xcode 15', () { + late BufferLogger logger; + late FakeProcessManager fakeProcessManager; + late Xcode xcode; + late IOSCoreDeviceControl deviceControl; + + setUp(() { + logger = BufferLogger.test(); + fakeProcessManager = FakeProcessManager.empty(); + final XcodeProjectInterpreter xcodeProjectInterpreter = XcodeProjectInterpreter.test( + processManager: fakeProcessManager, + version: Version(14, 0, 0), + ); + xcode = Xcode.test( + processManager: FakeProcessManager.any(), + xcodeProjectInterpreter: xcodeProjectInterpreter, + ); + deviceControl = IOSCoreDeviceControl( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + }); + + group('devicectl is not installed', () { + testWithoutContext('fails to get device list', () async { + final List devices = await deviceControl.getCoreDevices(); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl is not installed.')); + expect(devices.isEmpty, isTrue); + }); + + testWithoutContext('fails to install app', () async { + final bool status = await deviceControl.installApp(deviceId: 'device-id', bundlePath: '/path/to/bundle'); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl is not installed.')); + expect(status, isFalse); + }); + + testWithoutContext('fails to launch app', () async { + final bool status = await deviceControl.launchApp(deviceId: 'device-id', bundleId: 'com.example.flutterApp'); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl is not installed.')); + expect(status, isFalse); + }); + + testWithoutContext('fails to check if app is installed', () async { + final bool status = await deviceControl.isAppInstalled(deviceId: 'device-id', bundleId: 'com.example.flutterApp'); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl is not installed.')); + expect(status, isFalse); + }); + }); + }); + + group('Core Device Control', () { + late BufferLogger logger; + late FakeProcessManager fakeProcessManager; + late Xcode xcode; + late IOSCoreDeviceControl deviceControl; + + setUp(() { + logger = BufferLogger.test(); + fakeProcessManager = FakeProcessManager.empty(); + xcode = Xcode.test(processManager: FakeProcessManager.any()); + deviceControl = IOSCoreDeviceControl( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + }); + + group('install app', () { + const String deviceId = 'device-id'; + const String bundlePath = '/path/to/com.example.flutterApp'; + + testWithoutContext('Successful install', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "device", + "install", + "app", + "--device", + "00001234-0001234A3C03401E", + "build/ios/iphoneos/Runner.app", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + ], + "commandType" : "devicectl.device.install.app", + "environment" : { + + }, + "outcome" : "success", + "version" : "341" + }, + "result" : { + "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "installedApplications" : [ + { + "bundleID" : "com.example.bundle", + "databaseSequenceNumber" : 1230, + "databaseUUID" : "1234A567-D890-1B23-BCF4-D5D67A8D901E", + "installationURL" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/", + "launchServicesIdentifier" : "unknown", + "options" : { + + } + } + ] + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('install_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'install', + 'app', + '--device', + deviceId, + bundlePath, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.installApp( + deviceId: deviceId, + bundlePath: bundlePath, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(tempFile, isNot(exists)); + expect(status, true); + }); + + testWithoutContext('devicectl fails install', () async { + const String deviceControlOutput = ''' +{ + "error" : { + "code" : 1005, + "domain" : "com.apple.dt.CoreDeviceError", + "userInfo" : { + "NSLocalizedDescription" : { + "string" : "Could not obtain access to one or more requested file system resources because CoreDevice was unable to create bookmark data." + }, + "NSUnderlyingError" : { + "error" : { + "code" : 260, + "domain" : "NSCocoaErrorDomain", + "userInfo" : { + + } + } + } + } + }, + "info" : { + "arguments" : [ + "devicectl", + "device", + "install", + "app", + "--device", + "00001234-0001234A3C03401E", + "/path/to/app", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + ], + "commandType" : "devicectl.device.install.app", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "failed", + "version" : "341" + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('install_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'install', + 'app', + '--device', + deviceId, + bundlePath, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + exitCode: 1, + stderr: ''' +ERROR: Could not obtain access to one or more requested file system resources because CoreDevice was unable to create bookmark data. (com.apple.dt.CoreDeviceError error 1005.) + NSURL = file:///path/to/app +-------------------------------------------------------------------------------- +ERROR: The file couldn’t be opened because it doesn’t exist. (NSCocoaErrorDomain error 260.) +''' + )); + + final bool status = await deviceControl.installApp( + deviceId: deviceId, + bundlePath: bundlePath, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('ERROR: Could not obtain access to one or more requested file system')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('fails install because of unexpected JSON', () async { + const String deviceControlOutput = ''' +{ + "valid_unexpected_json": true +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('install_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'install', + 'app', + '--device', + deviceId, + bundlePath, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.installApp( + deviceId: deviceId, + bundlePath: bundlePath, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned unexpected JSON response')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('fails install because of invalid JSON', () async { + const String deviceControlOutput = ''' +invalid JSON +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('install_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'install', + 'app', + '--device', + deviceId, + bundlePath, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.installApp( + deviceId: deviceId, + bundlePath: bundlePath, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned non-JSON response')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + }); + + group('uninstall app', () { + const String deviceId = 'device-id'; + const String bundleId = 'com.example.flutterApp'; + + testWithoutContext('Successful uninstall', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "device", + "uninstall", + "app", + "--device", + "00001234-0001234A3C03401E", + "build/ios/iphoneos/Runner.app", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/uninstall_results.json" + ], + "commandType" : "devicectl.device.uninstall.app", + "environment" : { + + }, + "outcome" : "success", + "version" : "341" + }, + "result" : { + "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "uninstalledApplications" : [ + { + "bundleID" : "com.example.bundle" + } + ] + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('uninstall_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'uninstall', + 'app', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.uninstallApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(tempFile, isNot(exists)); + expect(status, true); + }); + + testWithoutContext('devicectl fails uninstall', () async { + const String deviceControlOutput = ''' +{ + "error" : { + "code" : 1005, + "domain" : "com.apple.dt.CoreDeviceError", + "userInfo" : { + "NSLocalizedDescription" : { + "string" : "Could not obtain access to one or more requested file system resources because CoreDevice was unable to create bookmark data." + }, + "NSUnderlyingError" : { + "error" : { + "code" : 260, + "domain" : "NSCocoaErrorDomain", + "userInfo" : { + + } + } + } + } + }, + "info" : { + "arguments" : [ + "devicectl", + "device", + "uninstall", + "app", + "--device", + "00001234-0001234A3C03401E", + "com.example.flutterApp", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/uninstall_results.json" + ], + "commandType" : "devicectl.device.uninstall.app", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "failed", + "version" : "341" + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('uninstall_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'uninstall', + 'app', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + exitCode: 1, + stderr: ''' +ERROR: Could not obtain access to one or more requested file system resources because CoreDevice was unable to create bookmark data. (com.apple.dt.CoreDeviceError error 1005.) + NSURL = file:///path/to/app +-------------------------------------------------------------------------------- +ERROR: The file couldn’t be opened because it doesn’t exist. (NSCocoaErrorDomain error 260.) +''' + )); + + final bool status = await deviceControl.uninstallApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('ERROR: Could not obtain access to one or more requested file system')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('fails uninstall because of unexpected JSON', () async { + const String deviceControlOutput = ''' +{ + "valid_unexpected_json": true +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('uninstall_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'uninstall', + 'app', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.uninstallApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned unexpected JSON response')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('fails uninstall because of invalid JSON', () async { + const String deviceControlOutput = ''' +invalid JSON +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('uninstall_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'uninstall', + 'app', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.uninstallApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned non-JSON response')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + }); + + group('launch app', () { + const String deviceId = 'device-id'; + const String bundleId = 'com.example.flutterApp'; + + testWithoutContext('Successful launch without launch args', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "device", + "process", + "launch", + "--device", + "00001234-0001234A3C03401E", + "com.example.flutterApp", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + ], + "commandType" : "devicectl.device.process.launch", + "environment" : { + + }, + "outcome" : "success", + "version" : "341" + }, + "result" : { + "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "launchOptions" : { + "activatedWhenStarted" : true, + "arguments" : [ + + ], + "environmentVariables" : { + "TERM" : "vt100" + }, + "platformSpecificOptions" : { + + }, + "startStopped" : false, + "terminateExistingInstances" : false, + "user" : { + "active" : true + } + }, + "process" : { + "auditToken" : [ + 12345, + 678 + ], + "executable" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/Runner", + "processIdentifier" : 1234 + } + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.launchApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(tempFile, isNot(exists)); + expect(status, true); + }); + + testWithoutContext('Successful launch with launch args', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "device", + "process", + "launch", + "--device", + "00001234-0001234A3C03401E", + "com.example.flutterApp", + "--arg1", + "--arg2", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + ], + "commandType" : "devicectl.device.process.launch", + "environment" : { + + }, + "outcome" : "success", + "version" : "341" + }, + "result" : { + "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "launchOptions" : { + "activatedWhenStarted" : true, + "arguments" : [ + + ], + "environmentVariables" : { + "TERM" : "vt100" + }, + "platformSpecificOptions" : { + + }, + "startStopped" : false, + "terminateExistingInstances" : false, + "user" : { + "active" : true + } + }, + "process" : { + "auditToken" : [ + 12345, + 678 + ], + "executable" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/Runner", + "processIdentifier" : 1234 + } + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + '--arg1', + '--arg2', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.launchApp( + deviceId: deviceId, + bundleId: bundleId, + launchArguments: ['--arg1', '--arg2'], + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(tempFile, isNot(exists)); + expect(status, true); + }); + + testWithoutContext('devicectl fails install', () async { + const String deviceControlOutput = ''' +{ + "error" : { + "code" : -10814, + "domain" : "NSOSStatusErrorDomain", + "userInfo" : { + "_LSFunction" : { + "string" : "runEvaluator" + }, + "_LSLine" : { + "int" : 1608 + } + } + }, + "info" : { + "arguments" : [ + "devicectl", + "device", + "process", + "launch", + "--device", + "00001234-0001234A3C03401E", + "com.example.flutterApp", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + ], + "commandType" : "devicectl.device.process.launch", + "environment" : { + + }, + "outcome" : "failed", + "version" : "341" + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + exitCode: 1, + stderr: ''' +ERROR: The operation couldn?t be completed. (OSStatus error -10814.) (NSOSStatusErrorDomain error -10814.) + _LSFunction = runEvaluator + _LSLine = 1608 +''' + )); + + final bool status = await deviceControl.launchApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('ERROR: The operation couldn?t be completed.')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('fails launch because of unexpected JSON', () async { + const String deviceControlOutput = ''' +{ + "valid_unexpected_json": true +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + + final bool status = await deviceControl.launchApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned unexpected JSON response')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('fails launch because of invalid JSON', () async { + const String deviceControlOutput = ''' +invalid JSON +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.launchApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned non-JSON response')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + }); + + group('list apps', () { + const String deviceId = 'device-id'; + const String bundleId = 'com.example.flutterApp'; + + testWithoutContext('Successfully parses apps', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "device", + "info", + "apps", + "--device", + "00001234-0001234A3C03401E", + "--bundle-id", + "com.example.flutterApp", + "--json-output", + "apps.txt" + ], + "commandType" : "devicectl.device.info.apps", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "success", + "version" : "341" + }, + "result" : { + "apps" : [ + { + "appClip" : false, + "builtByDeveloper" : true, + "bundleIdentifier" : "com.example.flutterApp", + "bundleVersion" : "1", + "defaultApp" : false, + "hidden" : false, + "internalApp" : false, + "name" : "Bundle", + "removable" : true, + "url" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/", + "version" : "1.0.0" + }, + { + "appClip" : true, + "builtByDeveloper" : false, + "bundleIdentifier" : "com.example.flutterApp2", + "bundleVersion" : "2", + "defaultApp" : true, + "hidden" : true, + "internalApp" : true, + "name" : "Bundle 2", + "removable" : false, + "url" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/", + "version" : "1.0.0" + } + ], + "defaultAppsIncluded" : false, + "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "hiddenAppsIncluded" : false, + "internalAppsIncluded" : false, + "matchingBundleIdentifier" : "com.example.flutterApp", + "removableAppsIncluded" : true + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_app_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'info', + 'apps', + '--device', + deviceId, + '--bundle-id', + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List apps = await deviceControl.getInstalledApps( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(tempFile, isNot(exists)); + expect(apps.length, 2); + + expect(apps[0].appClip, isFalse); + expect(apps[0].builtByDeveloper, isTrue); + expect(apps[0].bundleIdentifier, 'com.example.flutterApp'); + expect(apps[0].bundleVersion, '1'); + expect(apps[0].defaultApp, isFalse); + expect(apps[0].hidden, isFalse); + expect(apps[0].internalApp, isFalse); + expect(apps[0].name, 'Bundle'); + expect(apps[0].removable, isTrue); + expect(apps[0].url, 'file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/'); + expect(apps[0].version, '1.0.0'); + + expect(apps[1].appClip, isTrue); + expect(apps[1].builtByDeveloper, isFalse); + expect(apps[1].bundleIdentifier, 'com.example.flutterApp2'); + expect(apps[1].bundleVersion, '2'); + expect(apps[1].defaultApp, isTrue); + expect(apps[1].hidden, isTrue); + expect(apps[1].internalApp, isTrue); + expect(apps[1].name, 'Bundle 2'); + expect(apps[1].removable, isFalse); + expect(apps[1].url, 'file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/'); + expect(apps[1].version, '1.0.0'); + }); + + + testWithoutContext('Successfully find installed app', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "device", + "info", + "apps", + "--device", + "00001234-0001234A3C03401E", + "--bundle-id", + "com.example.flutterApp", + "--json-output", + "apps.txt" + ], + "commandType" : "devicectl.device.info.apps", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "success", + "version" : "341" + }, + "result" : { + "apps" : [ + { + "appClip" : false, + "builtByDeveloper" : true, + "bundleIdentifier" : "com.example.flutterApp", + "bundleVersion" : "1", + "defaultApp" : false, + "hidden" : false, + "internalApp" : false, + "name" : "Bundle", + "removable" : true, + "url" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/", + "version" : "1.0.0" + } + ], + "defaultAppsIncluded" : false, + "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "hiddenAppsIncluded" : false, + "internalAppsIncluded" : false, + "matchingBundleIdentifier" : "com.example.flutterApp", + "removableAppsIncluded" : true + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_app_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'info', + 'apps', + '--device', + deviceId, + '--bundle-id', + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.isAppInstalled( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(tempFile, isNot(exists)); + expect(status, true); + }); + + testWithoutContext('Succeeds but does not find app', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "device", + "info", + "apps", + "--device", + "00001234-0001234A3C03401E", + "--bundle-id", + "com.example.flutterApp", + "--json-output", + "apps.txt" + ], + "commandType" : "devicectl.device.info.apps", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "success", + "version" : "341" + }, + "result" : { + "apps" : [ + ], + "defaultAppsIncluded" : false, + "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "hiddenAppsIncluded" : false, + "internalAppsIncluded" : false, + "matchingBundleIdentifier" : "com.example.flutterApp", + "removableAppsIncluded" : true + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_app_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'info', + 'apps', + '--device', + deviceId, + '--bundle-id', + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.isAppInstalled( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('devicectl fails to get apps', () async { + const String deviceControlOutput = ''' +{ + "error" : { + "code" : 1000, + "domain" : "com.apple.dt.CoreDeviceError", + "userInfo" : { + "NSLocalizedDescription" : { + "string" : "The specified device was not found." + } + } + }, + "info" : { + "arguments" : [ + "devicectl", + "device", + "info", + "apps", + "--device", + "00001234-0001234A3C03401E", + "--bundle-id", + "com.example.flutterApp", + "--json-output", + "apps.txt" + ], + "commandType" : "devicectl.device.info.apps", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "failed", + "version" : "341" + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_app_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'info', + 'apps', + '--device', + deviceId, + '--bundle-id', + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + exitCode: 1, + stderr: ''' +ERROR: The specified device was not found. (com.apple.dt.CoreDeviceError error 1000.) +''' + )); + + final bool status = await deviceControl.isAppInstalled( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('ERROR: The specified device was not found.')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('fails launch because of unexpected JSON', () async { + const String deviceControlOutput = ''' +{ + "valid_unexpected_json": true +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_app_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'info', + 'apps', + '--device', + deviceId, + '--bundle-id', + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + + final bool status = await deviceControl.isAppInstalled( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned unexpected JSON response')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('fails launch because of invalid JSON', () async { + const String deviceControlOutput = ''' +invalid JSON +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_app_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'info', + 'apps', + '--device', + deviceId, + '--bundle-id', + bundleId, + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final bool status = await deviceControl.isAppInstalled( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned non-JSON response')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + }); + + group('list devices', () { + testWithoutContext('No devices', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "list", + "devices", + "--json-output", + "core_device_list.json" + ], + "commandType" : "devicectl.list.devices", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "success", + "version" : "325.3" + }, + "result" : { + "devices" : [ + + ] + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--timeout', + '5', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List devices = await deviceControl.getCoreDevices(); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(devices.isEmpty, isTrue); + }); + + testWithoutContext('All sections parsed', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "list", + "devices", + "--json-output", + "core_device_list.json" + ], + "commandType" : "devicectl.list.devices", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "success", + "version" : "325.3" + }, + "result" : { + "devices" : [ + { + "capabilities" : [ + ], + "connectionProperties" : { + }, + "deviceProperties" : { + }, + "hardwareProperties" : { + }, + "identifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "visibilityClass" : "default" + } + ] + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--timeout', + '5', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List devices = await deviceControl.getCoreDevices(); + expect(devices.length, 1); + + expect(devices[0].capabilities, isNotNull); + expect(devices[0].connectionProperties, isNotNull); + expect(devices[0].deviceProperties, isNotNull); + expect(devices[0].hardwareProperties, isNotNull); + expect(devices[0].coreDeviceIdentifer, '123456BB5-AEDE-7A22-B890-1234567890DD'); + expect(devices[0].visibilityClass, 'default'); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(tempFile, isNot(exists)); + }); + + testWithoutContext('All sections parsed, device missing sections', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "list", + "devices", + "--json-output", + "core_device_list.json" + ], + "commandType" : "devicectl.list.devices", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "success", + "version" : "325.3" + }, + "result" : { + "devices" : [ + { + "identifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "visibilityClass" : "default" + } + ] + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--timeout', + '5', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List devices = await deviceControl.getCoreDevices(); + expect(devices.length, 1); + + expect(devices[0].capabilities, isEmpty); + expect(devices[0].connectionProperties, isNull); + expect(devices[0].deviceProperties, isNull); + expect(devices[0].hardwareProperties, isNull); + expect(devices[0].coreDeviceIdentifer, '123456BB5-AEDE-7A22-B890-1234567890DD'); + expect(devices[0].visibilityClass, 'default'); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(tempFile, isNot(exists)); + }); + + testWithoutContext('capabilities parsed', () async { + const String deviceControlOutput = ''' +{ + "result" : { + "devices" : [ + { + "capabilities" : [ + { + "featureIdentifier" : "com.apple.coredevice.feature.spawnexecutable", + "name" : "Spawn Executable" + }, + { + "featureIdentifier" : "com.apple.coredevice.feature.launchapplication", + "name" : "Launch Application" + } + ] + } + ] + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--timeout', + '5', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List devices = await deviceControl.getCoreDevices(); + expect(devices.length, 1); + + expect(devices[0].capabilities.length, 2); + expect(devices[0].capabilities[0].featureIdentifier, 'com.apple.coredevice.feature.spawnexecutable'); + expect(devices[0].capabilities[0].name, 'Spawn Executable'); + expect(devices[0].capabilities[1].featureIdentifier, 'com.apple.coredevice.feature.launchapplication'); + expect(devices[0].capabilities[1].name, 'Launch Application'); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(tempFile, isNot(exists)); + }); + + testWithoutContext('connectionProperties parsed', () async { + const String deviceControlOutput = ''' +{ + "result" : { + "devices" : [ + { + "connectionProperties" : { + "authenticationType" : "manualPairing", + "isMobileDeviceOnly" : false, + "lastConnectionDate" : "2023-06-15T15:29:00.082Z", + "localHostnames" : [ + "Victorias-iPad.coredevice.local", + "00001234-0001234A3C03401E.coredevice.local", + "123456BB5-AEDE-7A22-B890-1234567890DD.coredevice.local" + ], + "pairingState" : "paired", + "potentialHostnames" : [ + "00001234-0001234A3C03401E.coredevice.local", + "123456BB5-AEDE-7A22-B890-1234567890DD.coredevice.local" + ], + "transportType" : "wired", + "tunnelIPAddress" : "fdf1:23c4:cd56::1", + "tunnelState" : "connected", + "tunnelTransportProtocol" : "tcp" + } + } + ] + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--timeout', + '5', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List devices = await deviceControl.getCoreDevices(); + expect(devices.length, 1); + + expect(devices[0].connectionProperties?.authenticationType, 'manualPairing'); + expect(devices[0].connectionProperties?.isMobileDeviceOnly, false); + expect(devices[0].connectionProperties?.lastConnectionDate, '2023-06-15T15:29:00.082Z'); + expect( + devices[0].connectionProperties?.localHostnames, + [ + 'Victorias-iPad.coredevice.local', + '00001234-0001234A3C03401E.coredevice.local', + '123456BB5-AEDE-7A22-B890-1234567890DD.coredevice.local', + ], + ); + expect(devices[0].connectionProperties?.pairingState, 'paired'); + expect(devices[0].connectionProperties?.potentialHostnames, [ + '00001234-0001234A3C03401E.coredevice.local', + '123456BB5-AEDE-7A22-B890-1234567890DD.coredevice.local', + ]); + expect(devices[0].connectionProperties?.transportType, 'wired'); + expect(devices[0].connectionProperties?.tunnelIPAddress, 'fdf1:23c4:cd56::1'); + expect(devices[0].connectionProperties?.tunnelState, 'connected'); + expect(devices[0].connectionProperties?.tunnelTransportProtocol, 'tcp'); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(tempFile, isNot(exists)); + }); + + testWithoutContext('deviceProperties parsed', () async { + const String deviceControlOutput = ''' +{ + "result" : { + "devices" : [ + { + "deviceProperties" : { + "bootedFromSnapshot" : true, + "bootedSnapshotName" : "com.apple.os.update-123456", + "bootState" : "booted", + "ddiServicesAvailable" : true, + "developerModeStatus" : "enabled", + "hasInternalOSBuild" : false, + "name" : "iPadName", + "osBuildUpdate" : "21A5248v", + "osVersionNumber" : "17.0", + "rootFileSystemIsWritable" : false, + "screenViewingURL" : "coredevice-devices:/viewDeviceByUUID?uuid=123456BB5-AEDE-7A22-B890-1234567890DD" + } + } + ] + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--timeout', + '5', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List devices = await deviceControl.getCoreDevices(); + expect(devices.length, 1); + + expect(devices[0].deviceProperties?.bootedFromSnapshot, true); + expect(devices[0].deviceProperties?.bootedSnapshotName, 'com.apple.os.update-123456'); + expect(devices[0].deviceProperties?.bootState, 'booted'); + expect(devices[0].deviceProperties?.ddiServicesAvailable, true); + expect(devices[0].deviceProperties?.developerModeStatus, 'enabled'); + expect(devices[0].deviceProperties?.hasInternalOSBuild, false); + expect(devices[0].deviceProperties?.name, 'iPadName'); + expect(devices[0].deviceProperties?.osBuildUpdate, '21A5248v'); + expect(devices[0].deviceProperties?.osVersionNumber, '17.0'); + expect(devices[0].deviceProperties?.rootFileSystemIsWritable, false); + expect(devices[0].deviceProperties?.screenViewingURL, 'coredevice-devices:/viewDeviceByUUID?uuid=123456BB5-AEDE-7A22-B890-1234567890DD'); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(tempFile, isNot(exists)); + }); + + testWithoutContext('hardwareProperties parsed', () async { + const String deviceControlOutput = r''' +{ + "result" : { + "devices" : [ + { + "hardwareProperties" : { + "cpuType" : { + "name" : "arm64e", + "subType" : 2, + "type" : 16777228 + }, + "deviceType" : "iPad", + "ecid" : 12345678903408542, + "hardwareModel" : "J617AP", + "internalStorageCapacity" : 128000000000, + "marketingName" : "iPad Pro (11-inch) (4th generation)\"", + "platform" : "iOS", + "productType" : "iPad14,3", + "serialNumber" : "HC123DHCQV", + "supportedCPUTypes" : [ + { + "name" : "arm64e", + "subType" : 2, + "type" : 16777228 + }, + { + "name" : "arm64", + "subType" : 0, + "type" : 16777228 + } + ], + "supportedDeviceFamilies" : [ + 1, + 2 + ], + "thinningProductType" : "iPad14,3-A", + "udid" : "00001234-0001234A3C03401E" + } + } + ] + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--timeout', + '5', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List devices = await deviceControl.getCoreDevices(); + expect(devices.length, 1); + + expect(devices[0].hardwareProperties?.cpuType, isNotNull); + expect(devices[0].hardwareProperties?.cpuType?.name, 'arm64e'); + expect(devices[0].hardwareProperties?.cpuType?.subType, 2); + expect(devices[0].hardwareProperties?.cpuType?.cpuType, 16777228); + expect(devices[0].hardwareProperties?.deviceType, 'iPad'); + expect(devices[0].hardwareProperties?.ecid, 12345678903408542); + expect(devices[0].hardwareProperties?.hardwareModel, 'J617AP'); + expect(devices[0].hardwareProperties?.internalStorageCapacity, 128000000000); + expect(devices[0].hardwareProperties?.marketingName, 'iPad Pro (11-inch) (4th generation)"'); + expect(devices[0].hardwareProperties?.platform, 'iOS'); + expect(devices[0].hardwareProperties?.productType, 'iPad14,3'); + expect(devices[0].hardwareProperties?.serialNumber, 'HC123DHCQV'); + expect(devices[0].hardwareProperties?.supportedCPUTypes, isNotNull); + expect(devices[0].hardwareProperties?.supportedCPUTypes?[0].name, 'arm64e'); + expect(devices[0].hardwareProperties?.supportedCPUTypes?[0].subType, 2); + expect(devices[0].hardwareProperties?.supportedCPUTypes?[0].cpuType, 16777228); + expect(devices[0].hardwareProperties?.supportedCPUTypes?[1].name, 'arm64'); + expect(devices[0].hardwareProperties?.supportedCPUTypes?[1].subType, 0); + expect(devices[0].hardwareProperties?.supportedCPUTypes?[1].cpuType, 16777228); + expect(devices[0].hardwareProperties?.supportedDeviceFamilies, [1, 2]); + expect(devices[0].hardwareProperties?.thinningProductType, 'iPad14,3-A'); + + expect(devices[0].hardwareProperties?.udid, '00001234-0001234A3C03401E'); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(tempFile, isNot(exists)); + }); + + group('Handles errors', () { + testWithoutContext('invalid json', () async { + const String deviceControlOutput = '''Invalid JSON'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--timeout', + '5', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List devices = await deviceControl.getCoreDevices(); + expect(devices.isEmpty, isTrue); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned non-JSON response: Invalid JSON')); + }); + + testWithoutContext('unexpected json', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "list", + "devices", + "--json-output", + "device_list.json" + ], + "commandType" : "devicectl.list.devices", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "success", + "version" : "325.3" + }, + "result" : [ + + ] +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--timeout', + '5', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List devices = await deviceControl.getCoreDevices(); + expect(devices.isEmpty, isTrue); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned unexpected JSON response:')); + }); + + testWithoutContext('When timeout is below minimum, default to minimum', () async { + const String deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "list", + "devices", + "--json-output", + "core_device_list.json" + ], + "commandType" : "devicectl.list.devices", + "environment" : { + "TERM" : "xterm-256color" + }, + "outcome" : "success", + "version" : "325.3" + }, + "result" : { + "devices" : [ + { + "identifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "visibilityClass" : "default" + } + ] + } +} +'''; + + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('core_device_list.json'); + fakeProcessManager.addCommand(FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--timeout', + '5', + '--json-output', + tempFile.path, + ], + onRun: () { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + )); + + final List devices = await deviceControl.getCoreDevices( + timeout: const Duration(seconds: 2), + ); + expect(devices.isNotEmpty, isTrue); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect( + logger.errorText, + contains('Timeout of 2 seconds is below the minimum timeout value ' + 'for devicectl. Changing the timeout to the minimum value of 5.'), + ); + }); + }); + }); + + + }); +} diff --git a/packages/flutter_tools/test/general.shard/ios/devices_test.dart b/packages/flutter_tools/test/general.shard/ios/devices_test.dart index cb222d9e3fe..146e05b2b70 100644 --- a/packages/flutter_tools/test/general.shard/ios/devices_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/devices_test.dart @@ -19,11 +19,13 @@ import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/device.dart'; import 'package:flutter_tools/src/device_port_forwarder.dart'; import 'package:flutter_tools/src/ios/application_package.dart'; +import 'package:flutter_tools/src/ios/core_devices.dart'; import 'package:flutter_tools/src/ios/devices.dart'; import 'package:flutter_tools/src/ios/ios_deploy.dart'; import 'package:flutter_tools/src/ios/ios_workflow.dart'; import 'package:flutter_tools/src/ios/iproxy.dart'; import 'package:flutter_tools/src/ios/mac.dart'; +import 'package:flutter_tools/src/ios/xcode_debug.dart'; import 'package:flutter_tools/src/macos/xcdevice.dart'; import 'package:test/fake.dart'; @@ -42,6 +44,8 @@ void main() { late IOSDeploy iosDeploy; late IMobileDevice iMobileDevice; late FileSystem fileSystem; + late IOSCoreDeviceControl coreDeviceControl; + late XcodeDebug xcodeDebug; setUp(() { final Artifacts artifacts = Artifacts.test(); @@ -61,6 +65,8 @@ void main() { logger: logger, processManager: FakeProcessManager.any(), ); + coreDeviceControl = FakeIOSCoreDeviceControl(); + xcodeDebug = FakeXcodeDebug(); }); testWithoutContext('successfully instantiates on Mac OS', () { @@ -72,12 +78,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ); expect(device.isSupported(), isTrue); }); @@ -91,11 +100,14 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.armv7, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ); expect(device.isSupported(), isFalse); }); @@ -109,12 +121,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: '1.0.0', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).majorSdkVersion, 1); expect(IOSDevice( 'device-123', @@ -124,12 +139,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: '13.1.1', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).majorSdkVersion, 13); expect(IOSDevice( 'device-123', @@ -139,12 +157,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: '10', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).majorSdkVersion, 10); expect(IOSDevice( 'device-123', @@ -154,12 +175,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: '0', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).majorSdkVersion, 0); expect(IOSDevice( 'device-123', @@ -169,12 +193,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: 'bogus', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).majorSdkVersion, 0); }); @@ -187,12 +214,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: '13.3.1', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).sdkVersion; Version expectedVersion = Version(13, 3, 1, text: '13.3.1'); expect(sdkVersion, isNotNull); @@ -207,12 +237,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: '13.3.1 (20ADBC)', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).sdkVersion; expectedVersion = Version(13, 3, 1, text: '13.3.1 (20ADBC)'); expect(sdkVersion, isNotNull); @@ -227,12 +260,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: '16.4.1(a) (20ADBC)', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).sdkVersion; expectedVersion = Version(16, 4, 1, text: '16.4.1(a) (20ADBC)'); expect(sdkVersion, isNotNull); @@ -247,12 +283,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: '0', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).sdkVersion; expectedVersion = Version(0, 0, 0, text: '0'); expect(sdkVersion, isNotNull); @@ -267,11 +306,14 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).sdkVersion; expect(sdkVersion, isNull); @@ -283,12 +325,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, sdkVersion: 'bogus', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ).sdkVersion; expect(sdkVersion, isNull); }); @@ -302,12 +347,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3 17C54', cpuArchitecture: DarwinArch.arm64, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ); expect(await device.sdkNameAndVersion,'iOS 13.3 17C54'); @@ -322,12 +370,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ); expect(device.supportsRuntimeMode(BuildMode.debug), true); @@ -348,12 +399,15 @@ void main() { platform: platform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ); }, throwsAssertionError, @@ -440,12 +494,15 @@ void main() { platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ); logReader1 = createLogReader(device, appPackage1, process1); logReader2 = createLogReader(device, appPackage2, process2); @@ -471,6 +528,8 @@ void main() { late IOSDeploy iosDeploy; late IMobileDevice iMobileDevice; late IOSWorkflow iosWorkflow; + late IOSCoreDeviceControl coreDeviceControl; + late XcodeDebug xcodeDebug; late IOSDevice device1; late IOSDevice device2; @@ -494,6 +553,8 @@ void main() { processManager: fakeProcessManager, logger: logger, ); + coreDeviceControl = FakeIOSCoreDeviceControl(); + xcodeDebug = FakeXcodeDebug(); device1 = IOSDevice( 'd83d5bc53967baa0ee18626ba87b6254b2ab5418', @@ -503,12 +564,15 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, logger: logger, platform: macPlatform, fileSystem: MemoryFileSystem.test(), connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ); device2 = IOSDevice( @@ -519,12 +583,15 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, logger: logger, platform: macPlatform, fileSystem: MemoryFileSystem.test(), connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ); }); @@ -781,6 +848,8 @@ void main() { late IOSDeploy iosDeploy; late IMobileDevice iMobileDevice; late IOSWorkflow iosWorkflow; + late IOSCoreDeviceControl coreDeviceControl; + late XcodeDebug xcodeDebug; late IOSDevice notConnected1; setUp(() { @@ -803,6 +872,8 @@ void main() { processManager: fakeProcessManager, logger: logger, ); + coreDeviceControl = FakeIOSCoreDeviceControl(); + xcodeDebug = FakeXcodeDebug(); notConnected1 = IOSDevice( '00000001-0000000000000000', name: 'iPad', @@ -811,12 +882,15 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + xcodeDebug: xcodeDebug, logger: logger, platform: macPlatform, fileSystem: MemoryFileSystem.test(), connectionInterface: DeviceConnectionInterface.attached, isConnected: false, devModeEnabled: true, + isCoreDevice: false, ); }); @@ -965,3 +1039,10 @@ class FakeProcess extends Fake implements Process { return true; } } + +class FakeXcodeDebug extends Fake implements XcodeDebug { + @override + bool get debugStarted => false; +} + +class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl {} diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart index 8c93d8fb4f8..6a5f5226fdb 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart @@ -12,10 +12,13 @@ import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/device.dart'; import 'package:flutter_tools/src/ios/application_package.dart'; +import 'package:flutter_tools/src/ios/core_devices.dart'; import 'package:flutter_tools/src/ios/devices.dart'; import 'package:flutter_tools/src/ios/ios_deploy.dart'; import 'package:flutter_tools/src/ios/iproxy.dart'; import 'package:flutter_tools/src/ios/mac.dart'; +import 'package:flutter_tools/src/ios/xcode_debug.dart'; +import 'package:test/fake.dart'; import '../../src/common.dart'; import '../../src/fake_process_manager.dart'; @@ -105,6 +108,28 @@ void main() { expect(processManager, hasNoRemainingExpectations); }); + testWithoutContext('IOSDevice.installApp uses devicectl for CoreDevices', () async { + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + uncompressedBundle: fileSystem.currentDirectory, + applicationPackage: bundleDirectory, + ); + + final FakeProcessManager processManager = FakeProcessManager.empty(); + + final IOSDevice device = setUpIOSDevice( + processManager: processManager, + fileSystem: fileSystem, + interfaceType: DeviceConnectionInterface.attached, + artifacts: artifacts, + isCoreDevice: true, + ); + final bool wasInstalled = await device.installApp(iosApp); + + expect(wasInstalled, true); + expect(processManager, hasNoRemainingExpectations); + }); + testWithoutContext('IOSDevice.uninstallApp calls ios-deploy correctly', () async { final IOSApp iosApp = PrebuiltIOSApp( projectBundleId: 'app', @@ -134,6 +159,28 @@ void main() { expect(processManager, hasNoRemainingExpectations); }); + testWithoutContext('IOSDevice.uninstallApp uses devicectl for CoreDevices', () async { + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + uncompressedBundle: fileSystem.currentDirectory, + applicationPackage: bundleDirectory, + ); + + final FakeProcessManager processManager = FakeProcessManager.empty(); + + final IOSDevice device = setUpIOSDevice( + processManager: processManager, + fileSystem: fileSystem, + interfaceType: DeviceConnectionInterface.attached, + artifacts: artifacts, + isCoreDevice: true, + ); + final bool wasUninstalled = await device.uninstallApp(iosApp); + + expect(wasUninstalled, true); + expect(processManager, hasNoRemainingExpectations); + }); + group('isAppInstalled', () { testWithoutContext('catches ProcessException from ios-deploy', () async { final IOSApp iosApp = PrebuiltIOSApp( @@ -263,6 +310,28 @@ void main() { expect(processManager, hasNoRemainingExpectations); expect(logger.traceText, contains(stderr)); }); + + testWithoutContext('uses devicectl for CoreDevices', () async { + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + uncompressedBundle: fileSystem.currentDirectory, + applicationPackage: bundleDirectory, + ); + + final FakeProcessManager processManager = FakeProcessManager.empty(); + + final IOSDevice device = setUpIOSDevice( + processManager: processManager, + fileSystem: fileSystem, + interfaceType: DeviceConnectionInterface.attached, + artifacts: artifacts, + isCoreDevice: true, + ); + final bool wasInstalled = await device.isAppInstalled(iosApp); + + expect(wasInstalled, true); + expect(processManager, hasNoRemainingExpectations); + }); }); testWithoutContext('IOSDevice.installApp catches ProcessException from ios-deploy', () async { @@ -314,6 +383,8 @@ void main() { expect(wasAppUninstalled, false); }); + + } IOSDevice setUpIOSDevice({ @@ -322,6 +393,7 @@ IOSDevice setUpIOSDevice({ Logger? logger, DeviceConnectionInterface? interfaceType, Artifacts? artifacts, + bool isCoreDevice = false, }) { logger ??= BufferLogger.test(); final FakePlatform platform = FakePlatform( @@ -357,9 +429,42 @@ IOSDevice setUpIOSDevice({ artifacts: artifacts, cache: cache, ), + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug(), iProxy: IProxy.test(logger: logger, processManager: processManager), connectionInterface: interfaceType ?? DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: isCoreDevice, ); } + +class FakeXcodeDebug extends Fake implements XcodeDebug {} + +class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl { + @override + Future installApp({ + required String deviceId, + required String bundlePath, + }) async { + + return true; + } + + @override + Future uninstallApp({ + required String deviceId, + required String bundleId, + }) async { + + return true; + } + + @override + Future isAppInstalled({ + required String deviceId, + required String bundleId, + }) async { + return true; + } +} diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_logger_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_logger_test.dart index 9f65a67072a..8b25f528ab1 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 @@ -349,9 +349,117 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt }); }); - group('both syslog and debugger stream', () { + group('Determine which loggers to use', () { + testWithoutContext('for physically attached CoreDevice', () { + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, + ), + majorSdkVersion: 17, + isCoreDevice: true, + ); - testWithoutContext('useBothLogDeviceReaders is true when CI option is true and sdk is at least 16', () { + expect(logReader.useSyslogLogging, isTrue); + expect(logReader.useUnifiedLogging, isTrue); + expect(logReader.useIOSDeployLogging, isFalse); + expect(logReader.usingMultipleLoggingSources, isTrue); + }); + + testWithoutContext('for wirelessly attached CoreDevice', () { + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, + ), + majorSdkVersion: 17, + isCoreDevice: true, + isWirelesslyConnected: true, + ); + + expect(logReader.useSyslogLogging, isFalse); + expect(logReader.useUnifiedLogging, isTrue); + expect(logReader.useIOSDeployLogging, isFalse); + expect(logReader.usingMultipleLoggingSources, isFalse); + }); + + testWithoutContext('for iOS 12 or less device', () { + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, + ), + majorSdkVersion: 12, + ); + + expect(logReader.useSyslogLogging, isTrue); + expect(logReader.useUnifiedLogging, isFalse); + expect(logReader.useIOSDeployLogging, isFalse); + expect(logReader.usingMultipleLoggingSources, isFalse); + }); + + testWithoutContext('for iOS 13 or greater non-CoreDevice', () { + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, + ), + majorSdkVersion: 13, + ); + + expect(logReader.useSyslogLogging, isFalse); + expect(logReader.useUnifiedLogging, isTrue); + expect(logReader.useIOSDeployLogging, isTrue); + expect(logReader.usingMultipleLoggingSources, isTrue); + }); + + 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.debuggerAttached = true; + logReader.debuggerStream = iosDeployDebugger; + + expect(logReader.useSyslogLogging, isFalse); + expect(logReader.useUnifiedLogging, isFalse); + expect(logReader.useIOSDeployLogging, isTrue); + expect(logReader.usingMultipleLoggingSources, isFalse); + }); + + testWithoutContext('for iOS 16 or greater non-CoreDevice', () { + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, + ), + majorSdkVersion: 16, + ); + + expect(logReader.useSyslogLogging, isFalse); + expect(logReader.useUnifiedLogging, isTrue); + expect(logReader.useIOSDeployLogging, isTrue); + expect(logReader.usingMultipleLoggingSources, isTrue); + }); + + testWithoutContext('for iOS 16 or greater non-CoreDevice in CI', () { final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( iMobileDevice: IMobileDevice( artifacts: artifacts, @@ -363,39 +471,13 @@ Runner(libsystem_asl.dylib)[297] : libMobileGestalt majorSdkVersion: 16, ); - expect(logReader.useBothLogDeviceReaders, isTrue); + expect(logReader.useSyslogLogging, isTrue); + expect(logReader.useUnifiedLogging, isTrue); + expect(logReader.useIOSDeployLogging, isTrue); + expect(logReader.usingMultipleLoggingSources, isTrue); }); - testWithoutContext('useBothLogDeviceReaders is false when sdk is less than 16', () { - final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( - iMobileDevice: IMobileDevice( - artifacts: artifacts, - processManager: processManager, - cache: fakeCache, - logger: logger, - ), - usingCISystem: true, - majorSdkVersion: 15, - ); - - expect(logReader.useBothLogDeviceReaders, isFalse); - }); - - testWithoutContext('useBothLogDeviceReaders is false when CI option is false', () { - final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( - iMobileDevice: IMobileDevice( - artifacts: artifacts, - processManager: processManager, - cache: fakeCache, - logger: logger, - ), - majorSdkVersion: 16, - ); - - expect(logReader.useBothLogDeviceReaders, isFalse); - }); - - testWithoutContext('syslog only sends flutter messages to stream when useBothLogDeviceReaders is true', () async { + testWithoutContext('syslog sends flutter messages to stream when useSyslogLogging is true', () async { processManager.addCommand( FakeCommand( command: [ @@ -422,7 +504,7 @@ May 30 13:56:28 Runner(Flutter)[2037] : [VERBOSE-2:FlutterDarwinContextM ); final List lines = await logReader.logLines.toList(); - expect(logReader.useBothLogDeviceReaders, isTrue); + expect(logReader.useSyslogLogging, isTrue); expect(processManager, hasNoRemainingExpectations); expect(lines, [ 'flutter: The Dart VM service is listening on http://127.0.0.1:63098/35ZezGIQLnw=/', @@ -430,7 +512,42 @@ May 30 13:56:28 Runner(Flutter)[2037] : [VERBOSE-2:FlutterDarwinContextM ]); }); - testWithoutContext('IOSDeviceLogReader uses both syslog and ios-deploy debugger', () async { + testWithoutContext('IOSDeviceLogReader only uses ios-deploy debugger when attached and not in CI', () async { + final Stream debuggingLogs = Stream.fromIterable([ + '(lldb) 2023-05-30 13:48:52.461894-0500 Runner[2019:1101495] [VERBOSE-2:FlutterDarwinContextMetalImpeller.mm(39)] Using the Impeller rendering backend.', + '', + ]); + + final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( + iMobileDevice: IMobileDevice( + artifacts: artifacts, + processManager: processManager, + cache: fakeCache, + logger: logger, + ), + majorSdkVersion: 16, + ); + final FakeIOSDeployDebugger iosDeployDebugger = FakeIOSDeployDebugger(); + iosDeployDebugger.debuggerAttached = true; + iosDeployDebugger.logLines = debuggingLogs; + logReader.debuggerStream = iosDeployDebugger; + final Future> logLines = logReader.logLines.toList(); + final List lines = await logLines; + + expect(logReader.useIOSDeployLogging, isTrue); + expect(logReader.useSyslogLogging, isFalse); + expect(logReader.useUnifiedLogging, isFalse); + expect(logReader.usingMultipleLoggingSources, 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.', + ), + isTrue, + ); + }); + + testWithoutContext('IOSDeviceLogReader uses both syslog and ios-deploy debugger for CI and filters duplicate messages', () async { processManager.addCommand( FakeCommand( command: [ @@ -465,7 +582,9 @@ May 30 13:56:28 Runner(Flutter)[2037] : [VERBOSE-2:FlutterDarwinContextM final Future> logLines = logReader.logLines.toList(); final List lines = await logLines; - expect(logReader.useBothLogDeviceReaders, isTrue); + expect(logReader.useSyslogLogging, isTrue); + expect(logReader.useIOSDeployLogging, isTrue); + expect(logReader.usingMultipleLoggingSources, isTrue); expect(processManager, hasNoRemainingExpectations); expect(lines.length, 3); expect(lines, containsAll([ @@ -473,38 +592,6 @@ May 30 13:56:28 Runner(Flutter)[2037] : [VERBOSE-2:FlutterDarwinContextM 'flutter: The Dart VM service is listening on http://127.0.0.1:63098/35ZezGIQLnw=/', 'flutter: Check for duplicate', ])); - - }); - - testWithoutContext('IOSDeviceLogReader only uses ios-deploy debugger when useBothLogDeviceReaders is false', () async { - final Stream debuggingLogs = Stream.fromIterable([ - '(lldb) 2023-05-30 13:48:52.461894-0500 Runner[2019:1101495] [VERBOSE-2:FlutterDarwinContextMetalImpeller.mm(39)] Using the Impeller rendering backend.', - '', - ]); - - final IOSDeviceLogReader logReader = IOSDeviceLogReader.test( - iMobileDevice: IMobileDevice( - artifacts: artifacts, - processManager: processManager, - cache: fakeCache, - logger: logger, - ), - majorSdkVersion: 16, - ); - final FakeIOSDeployDebugger iosDeployDebugger = FakeIOSDeployDebugger(); - iosDeployDebugger.logLines = debuggingLogs; - logReader.debuggerStream = iosDeployDebugger; - final Future> logLines = logReader.logLines.toList(); - final List lines = await logLines; - - expect(logReader.useBothLogDeviceReaders, isFalse); - expect(processManager, hasNoRemainingExpectations); - expect( - lines.contains( - '(lldb) 2023-05-30 13:48:52.461894-0500 Runner[2019:1101495] [VERBOSE-2:FlutterDarwinContextMetalImpeller.mm(39)] Using the Impeller rendering backend.', - ), - isTrue, - ); }); }); } 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 082d70b77be..abe1e850a4a 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_project_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_project_test.dart @@ -10,11 +10,14 @@ import 'package:flutter_tools/src/base/platform.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/device.dart'; +import 'package:flutter_tools/src/ios/core_devices.dart'; import 'package:flutter_tools/src/ios/devices.dart'; import 'package:flutter_tools/src/ios/ios_deploy.dart'; import 'package:flutter_tools/src/ios/iproxy.dart'; import 'package:flutter_tools/src/ios/mac.dart'; +import 'package:flutter_tools/src/ios/xcode_debug.dart'; import 'package:flutter_tools/src/project.dart'; +import 'package:test/fake.dart'; import '../../src/common.dart'; import '../../src/context.dart'; @@ -94,6 +97,8 @@ IOSDevice setUpIOSDevice(FileSystem fileSystem) { cache: Cache.test(processManager: processManager), ), iMobileDevice: IMobileDevice.test(processManager: processManager), + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug(), platform: platform, name: 'iPhone 1', sdkVersion: '13.3', @@ -102,5 +107,10 @@ IOSDevice setUpIOSDevice(FileSystem fileSystem) { connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: false, ); } + +class FakeXcodeDebug extends Fake implements XcodeDebug {} + +class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl {} diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart index be0d51a3438..d7f354cd614 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:fake_async/fake_async.dart'; import 'package:file/memory.dart'; import 'package:file_testing/file_testing.dart'; @@ -13,11 +15,14 @@ import 'package:flutter_tools/src/base/version.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/device.dart'; +import 'package:flutter_tools/src/device_port_forwarder.dart'; import 'package:flutter_tools/src/ios/application_package.dart'; +import 'package:flutter_tools/src/ios/core_devices.dart'; import 'package:flutter_tools/src/ios/devices.dart'; import 'package:flutter_tools/src/ios/ios_deploy.dart'; import 'package:flutter_tools/src/ios/iproxy.dart'; import 'package:flutter_tools/src/ios/mac.dart'; +import 'package:flutter_tools/src/ios/xcode_debug.dart'; import 'package:flutter_tools/src/ios/xcodeproj.dart'; import 'package:flutter_tools/src/macos/xcode.dart'; import 'package:flutter_tools/src/project.dart'; @@ -25,6 +30,7 @@ import 'package:test/fake.dart'; import '../../src/common.dart'; import '../../src/context.dart' hide FakeXcodeProjectInterpreter; +import '../../src/fake_devices.dart'; import '../../src/fake_process_manager.dart'; import '../../src/fakes.dart'; @@ -287,13 +293,363 @@ void main() { Xcode: () => xcode, }, skip: true); // TODO(zanderso): clean up with https://github.com/flutter/flutter/issues/60675 }); + + group('IOSDevice.startApp for CoreDevice', () { + late FileSystem fileSystem; + late FakeProcessManager processManager; + late BufferLogger logger; + late Xcode xcode; + late FakeXcodeProjectInterpreter fakeXcodeProjectInterpreter; + late XcodeProjectInfo projectInfo; + + setUp(() { + logger = BufferLogger.test(); + fileSystem = MemoryFileSystem.test(); + processManager = FakeProcessManager.empty(); + projectInfo = XcodeProjectInfo( + ['Runner'], + ['Debug', 'Release'], + ['Runner'], + logger, + ); + fakeXcodeProjectInterpreter = FakeXcodeProjectInterpreter(projectInfo: projectInfo); + xcode = Xcode.test(processManager: FakeProcessManager.any(), xcodeProjectInterpreter: fakeXcodeProjectInterpreter); + fileSystem.file('foo/.packages') + ..createSync(recursive: true) + ..writeAsStringSync('\n'); + }); + + group('in release mode', () { + testUsingContext('suceeds when install and launch succeed', () async { + final IOSDevice iosDevice = setUpIOSDevice( + fileSystem: fileSystem, + processManager: FakeProcessManager.any(), + logger: logger, + artifacts: artifacts, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl(), + ); + setUpIOSProject(fileSystem); + final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); + final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); + + final LaunchResult launchResult = await iosDevice.startApp( + buildableIOSApp, + debuggingOptions: DebuggingOptions.disabled(BuildInfo.release), + platformArgs: {}, + ); + + expect(fileSystem.directory('build/ios/iphoneos'), exists); + expect(launchResult.started, true); + expect(processManager, hasNoRemainingExpectations); + }, overrides: { + ProcessManager: () => FakeProcessManager.any(), + FileSystem: () => fileSystem, + Logger: () => logger, + Platform: () => macPlatform, + XcodeProjectInterpreter: () => fakeXcodeProjectInterpreter, + Xcode: () => xcode, + }); + + testUsingContext('fails when install fails', () async { + final IOSDevice iosDevice = setUpIOSDevice( + fileSystem: fileSystem, + processManager: FakeProcessManager.any(), + logger: logger, + artifacts: artifacts, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl( + installSuccess: false, + ), + ); + setUpIOSProject(fileSystem); + final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); + final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); + + final LaunchResult launchResult = await iosDevice.startApp( + buildableIOSApp, + debuggingOptions: DebuggingOptions.disabled(BuildInfo.release), + platformArgs: {}, + ); + + expect(fileSystem.directory('build/ios/iphoneos'), exists); + expect(launchResult.started, false); + expect(processManager, hasNoRemainingExpectations); + }, overrides: { + ProcessManager: () => FakeProcessManager.any(), + FileSystem: () => fileSystem, + Logger: () => logger, + Platform: () => macPlatform, + XcodeProjectInterpreter: () => fakeXcodeProjectInterpreter, + Xcode: () => xcode, + }); + + testUsingContext('fails when launch fails', () async { + final IOSDevice iosDevice = setUpIOSDevice( + fileSystem: fileSystem, + processManager: FakeProcessManager.any(), + logger: logger, + artifacts: artifacts, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl( + launchSuccess: false, + ), + ); + setUpIOSProject(fileSystem); + final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); + final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); + + final LaunchResult launchResult = await iosDevice.startApp( + buildableIOSApp, + debuggingOptions: DebuggingOptions.disabled(BuildInfo.release), + platformArgs: {}, + ); + + expect(fileSystem.directory('build/ios/iphoneos'), exists); + expect(launchResult.started, false); + expect(processManager, hasNoRemainingExpectations); + }, overrides: { + ProcessManager: () => FakeProcessManager.any(), + FileSystem: () => fileSystem, + Logger: () => logger, + Platform: () => macPlatform, + XcodeProjectInterpreter: () => fakeXcodeProjectInterpreter, + Xcode: () => xcode, + }); + + testUsingContext('ensure arguments passed to launch', () async { + final FakeIOSCoreDeviceControl coreDeviceControl = FakeIOSCoreDeviceControl(); + final IOSDevice iosDevice = setUpIOSDevice( + fileSystem: fileSystem, + processManager: FakeProcessManager.any(), + logger: logger, + artifacts: artifacts, + isCoreDevice: true, + coreDeviceControl: coreDeviceControl, + ); + setUpIOSProject(fileSystem); + final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); + final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); + + final LaunchResult launchResult = await iosDevice.startApp( + buildableIOSApp, + debuggingOptions: DebuggingOptions.disabled(BuildInfo.release), + platformArgs: {}, + ); + + expect(fileSystem.directory('build/ios/iphoneos'), exists); + expect(launchResult.started, true); + expect(processManager, hasNoRemainingExpectations); + expect(coreDeviceControl.argumentsUsedForLaunch, isNotNull); + expect(coreDeviceControl.argumentsUsedForLaunch, contains('--enable-dart-profiling')); + }, overrides: { + ProcessManager: () => FakeProcessManager.any(), + FileSystem: () => fileSystem, + Logger: () => logger, + Platform: () => macPlatform, + XcodeProjectInterpreter: () => fakeXcodeProjectInterpreter, + Xcode: () => xcode, + }); + + }); + + group('in debug mode', () { + + testUsingContext('succeeds', () async { + final IOSDevice iosDevice = setUpIOSDevice( + fileSystem: fileSystem, + processManager: FakeProcessManager.any(), + logger: logger, + artifacts: artifacts, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug( + expectedProject: XcodeDebugProject( + scheme: 'Runner', + xcodeWorkspace: fileSystem.directory('/ios/Runner.xcworkspace'), + xcodeProject: fileSystem.directory('/ios/Runner.xcodeproj'), + ), + expectedDeviceId: '123', + expectedLaunchArguments: ['--enable-dart-profiling'], + ), + ); + + setUpIOSProject(fileSystem); + final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); + final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); + + final FakeDeviceLogReader deviceLogReader = FakeDeviceLogReader(); + + iosDevice.portForwarder = const NoOpDevicePortForwarder(); + iosDevice.setLogReader(buildableIOSApp, deviceLogReader); + + // Start writing messages to the log reader. + Timer.run(() { + deviceLogReader.addLine('Foo'); + deviceLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:456'); + }); + + final LaunchResult launchResult = await iosDevice.startApp( + buildableIOSApp, + debuggingOptions: DebuggingOptions.enabled(const BuildInfo( + BuildMode.debug, + null, + buildName: '1.2.3', + buildNumber: '4', + treeShakeIcons: false, + )), + platformArgs: {}, + ); + + expect(logger.errorText, isEmpty); + expect(fileSystem.directory('build/ios/iphoneos'), exists); + expect(launchResult.started, true); + expect(processManager, hasNoRemainingExpectations); + }, overrides: { + ProcessManager: () => FakeProcessManager.any(), + FileSystem: () => fileSystem, + Logger: () => logger, + Platform: () => macPlatform, + XcodeProjectInterpreter: () => fakeXcodeProjectInterpreter, + Xcode: () => xcode, + }); + + testUsingContext('fails when Xcode project is not found', () async { + final IOSDevice iosDevice = setUpIOSDevice( + fileSystem: fileSystem, + processManager: FakeProcessManager.any(), + logger: logger, + artifacts: artifacts, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl() + ); + setUpIOSProject(fileSystem); + final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); + final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); + + final LaunchResult launchResult = await iosDevice.startApp( + buildableIOSApp, + debuggingOptions: DebuggingOptions.enabled(const BuildInfo( + BuildMode.debug, + null, + buildName: '1.2.3', + buildNumber: '4', + treeShakeIcons: false, + )), + platformArgs: {}, + ); + expect(logger.errorText, contains('Xcode project not found')); + expect(fileSystem.directory('build/ios/iphoneos'), exists); + expect(launchResult.started, false); + expect(processManager, hasNoRemainingExpectations); + }, overrides: { + ProcessManager: () => FakeProcessManager.any(), + FileSystem: () => fileSystem, + Logger: () => logger, + Platform: () => macPlatform, + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreter(), + Xcode: () => xcode, + }); + + testUsingContext('fails when Xcode workspace is not found', () async { + final IOSDevice iosDevice = setUpIOSDevice( + fileSystem: fileSystem, + processManager: FakeProcessManager.any(), + logger: logger, + artifacts: artifacts, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl() + ); + setUpIOSProject(fileSystem, createWorkspace: false); + final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); + final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); + + final LaunchResult launchResult = await iosDevice.startApp( + buildableIOSApp, + debuggingOptions: DebuggingOptions.enabled(const BuildInfo( + BuildMode.debug, + null, + buildName: '1.2.3', + buildNumber: '4', + treeShakeIcons: false, + )), + platformArgs: {}, + ); + expect(logger.errorText, contains('Unable to get Xcode workspace')); + expect(fileSystem.directory('build/ios/iphoneos'), exists); + expect(launchResult.started, false); + expect(processManager, hasNoRemainingExpectations); + }, overrides: { + ProcessManager: () => FakeProcessManager.any(), + FileSystem: () => fileSystem, + Logger: () => logger, + Platform: () => macPlatform, + XcodeProjectInterpreter: () => fakeXcodeProjectInterpreter, + Xcode: () => xcode, + }); + + testUsingContext('fails when scheme is not found', () async { + final IOSDevice iosDevice = setUpIOSDevice( + fileSystem: fileSystem, + processManager: FakeProcessManager.any(), + logger: logger, + artifacts: artifacts, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl() + ); + setUpIOSProject(fileSystem); + final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); + final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); + + final FakeDeviceLogReader deviceLogReader = FakeDeviceLogReader(); + + iosDevice.portForwarder = const NoOpDevicePortForwarder(); + iosDevice.setLogReader(buildableIOSApp, deviceLogReader); + + // Start writing messages to the log reader. + Timer.run(() { + deviceLogReader.addLine('Foo'); + deviceLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:456'); + }); + + expect(() async => iosDevice.startApp( + buildableIOSApp, + debuggingOptions: DebuggingOptions.enabled(const BuildInfo( + BuildMode.debug, + 'Flavor', + buildName: '1.2.3', + buildNumber: '4', + treeShakeIcons: false, + )), + platformArgs: {}, + ), throwsToolExit()); + }, overrides: { + ProcessManager: () => FakeProcessManager.any(), + FileSystem: () => fileSystem, + Logger: () => logger, + Platform: () => macPlatform, + XcodeProjectInterpreter: () => fakeXcodeProjectInterpreter, + Xcode: () => xcode, + }); + }); + }); } -void setUpIOSProject(FileSystem fileSystem) { +void setUpIOSProject(FileSystem fileSystem, {bool createWorkspace = true}) { fileSystem.file('pubspec.yaml').createSync(); fileSystem.file('.packages').writeAsStringSync('\n'); fileSystem.directory('ios').createSync(); - fileSystem.directory('ios/Runner.xcworkspace').createSync(); + if (createWorkspace) { + fileSystem.directory('ios/Runner.xcworkspace').createSync(); + } fileSystem.file('ios/Runner.xcodeproj/project.pbxproj').createSync(recursive: true); // This is the expected output directory. fileSystem.directory('build/ios/iphoneos/My Super Awesome App.app').createSync(recursive: true); @@ -305,6 +661,9 @@ IOSDevice setUpIOSDevice({ Logger? logger, ProcessManager? processManager, Artifacts? artifacts, + bool isCoreDevice = false, + IOSCoreDeviceControl? coreDeviceControl, + FakeXcodeDebug? xcodeDebug, }) { artifacts ??= Artifacts.test(); final Cache cache = Cache.test( @@ -336,10 +695,13 @@ IOSDevice setUpIOSDevice({ artifacts: artifacts, cache: cache, ), + coreDeviceControl: coreDeviceControl ?? FakeIOSCoreDeviceControl(), + xcodeDebug: xcodeDebug ?? FakeXcodeDebug(), cpuArchitecture: DarwinArch.arm64, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, devModeEnabled: true, + isCoreDevice: isCoreDevice, ); } @@ -381,3 +743,70 @@ class FakeXcodeProjectInterpreter extends Fake implements XcodeProjectInterprete Duration timeout = const Duration(minutes: 1), }) async => buildSettings; } + +class FakeXcodeDebug extends Fake implements XcodeDebug { + FakeXcodeDebug({ + this.debugSuccess = true, + this.expectedProject, + this.expectedDeviceId, + this.expectedLaunchArguments, + }); + + final bool debugSuccess; + + final XcodeDebugProject? expectedProject; + final String? expectedDeviceId; + final List? expectedLaunchArguments; + + @override + Future debugApp({ + required XcodeDebugProject project, + required String deviceId, + required List launchArguments, + }) async { + if (expectedProject != null) { + expect(project.scheme, expectedProject!.scheme); + expect(project.xcodeWorkspace.path, expectedProject!.xcodeWorkspace.path); + expect(project.xcodeProject.path, expectedProject!.xcodeProject.path); + expect(project.isTemporaryProject, expectedProject!.isTemporaryProject); + } + if (expectedDeviceId != null) { + expect(deviceId, expectedDeviceId); + } + if (expectedLaunchArguments != null) { + expect(expectedLaunchArguments, launchArguments); + } + return debugSuccess; + } +} + +class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl { + FakeIOSCoreDeviceControl({ + this.installSuccess = true, + this.launchSuccess = true + }); + + final bool installSuccess; + final bool launchSuccess; + List? _launchArguments; + + List? get argumentsUsedForLaunch => _launchArguments; + + @override + Future installApp({ + required String deviceId, + required String bundlePath, + }) async { + return installSuccess; + } + + @override + Future launchApp({ + required String deviceId, + required String bundleId, + List launchArguments = const [], + }) async { + _launchArguments = launchArguments; + return launchSuccess; + } +} diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart index 5d01888b0c4..8e8e2235a6b 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart @@ -5,20 +5,25 @@ import 'dart:async'; import 'dart:convert'; +import 'package:fake_async/fake_async.dart'; import 'package:file/memory.dart'; import 'package:flutter_tools/src/artifacts.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/platform.dart'; +import 'package:flutter_tools/src/base/process.dart'; +import 'package:flutter_tools/src/base/template.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/device.dart'; import 'package:flutter_tools/src/device_port_forwarder.dart'; import 'package:flutter_tools/src/ios/application_package.dart'; +import 'package:flutter_tools/src/ios/core_devices.dart'; import 'package:flutter_tools/src/ios/devices.dart'; import 'package:flutter_tools/src/ios/ios_deploy.dart'; import 'package:flutter_tools/src/ios/iproxy.dart'; import 'package:flutter_tools/src/ios/mac.dart'; +import 'package:flutter_tools/src/ios/xcode_debug.dart'; import 'package:flutter_tools/src/mdns_discovery.dart'; import 'package:test/fake.dart'; @@ -601,6 +606,212 @@ void main() { expect(await device.stopApp(iosApp), false); expect(processManager, hasNoRemainingExpectations); }); + + group('IOSDevice.startApp for CoreDevice', () { + group('in debug mode', () { + testUsingContext('succeeds', () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final FakeProcessManager processManager = FakeProcessManager.empty(); + + final Directory temporaryXcodeProjectDirectory = fileSystem.systemTempDirectory.childDirectory('flutter_empty_xcode.rand0'); + final Directory bundleLocation = fileSystem.currentDirectory; + final IOSDevice device = setUpIOSDevice( + processManager: processManager, + fileSystem: fileSystem, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug( + expectedProject: XcodeDebugProject( + scheme: 'Runner', + xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'), + xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'), + ), + expectedDeviceId: '123', + expectedLaunchArguments: ['--enable-dart-profiling'], + expectedBundlePath: bundleLocation.path, + ) + ); + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + bundleName: 'Runner', + uncompressedBundle: bundleLocation, + applicationPackage: bundleLocation, + ); + final FakeDeviceLogReader deviceLogReader = FakeDeviceLogReader(); + + device.portForwarder = const NoOpDevicePortForwarder(); + device.setLogReader(iosApp, deviceLogReader); + + // Start writing messages to the log reader. + Timer.run(() { + deviceLogReader.addLine('Foo'); + deviceLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:456'); + }); + + final LaunchResult launchResult = await device.startApp(iosApp, + prebuiltApplication: true, + debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), + platformArgs: {}, + ); + + expect(launchResult.started, true); + }); + + testUsingContext('prints warning message if it takes too long to start debugging', () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final FakeProcessManager processManager = FakeProcessManager.empty(); + final BufferLogger logger = BufferLogger.test(); + final Directory temporaryXcodeProjectDirectory = fileSystem.systemTempDirectory.childDirectory('flutter_empty_xcode.rand0'); + final Directory bundleLocation = fileSystem.currentDirectory; + final Completer completer = Completer(); + final FakeXcodeDebug xcodeDebug = FakeXcodeDebug( + expectedProject: XcodeDebugProject( + scheme: 'Runner', + xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'), + xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'), + ), + expectedDeviceId: '123', + expectedLaunchArguments: ['--enable-dart-profiling'], + expectedBundlePath: bundleLocation.path, + completer: completer, + ); + final IOSDevice device = setUpIOSDevice( + processManager: processManager, + fileSystem: fileSystem, + logger: logger, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: xcodeDebug, + ); + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + bundleName: 'Runner', + uncompressedBundle: bundleLocation, + applicationPackage: bundleLocation, + ); + final FakeDeviceLogReader deviceLogReader = FakeDeviceLogReader(); + + device.portForwarder = const NoOpDevicePortForwarder(); + device.setLogReader(iosApp, deviceLogReader); + + // Start writing messages to the log reader. + Timer.run(() { + deviceLogReader.addLine('Foo'); + deviceLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:456'); + }); + + FakeAsync().run((FakeAsync fakeAsync) { + device.startApp(iosApp, + prebuiltApplication: true, + debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), + platformArgs: {}, + ); + + fakeAsync.flushTimers(); + expect(logger.errorText, contains('Xcode is taking longer than expected to start debugging the app. Ensure the project is opened in Xcode.')); + completer.complete(); + }); + }); + + testUsingContext('succeeds with shutdown hook added when running from CI', () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final FakeProcessManager processManager = FakeProcessManager.empty(); + + final Directory temporaryXcodeProjectDirectory = fileSystem.systemTempDirectory.childDirectory('flutter_empty_xcode.rand0'); + final Directory bundleLocation = fileSystem.currentDirectory; + final IOSDevice device = setUpIOSDevice( + processManager: processManager, + fileSystem: fileSystem, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug( + expectedProject: XcodeDebugProject( + scheme: 'Runner', + xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'), + xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'), + ), + expectedDeviceId: '123', + expectedLaunchArguments: ['--enable-dart-profiling'], + expectedBundlePath: bundleLocation.path, + ) + ); + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + bundleName: 'Runner', + uncompressedBundle: bundleLocation, + applicationPackage: bundleLocation, + ); + final FakeDeviceLogReader deviceLogReader = FakeDeviceLogReader(); + + device.portForwarder = const NoOpDevicePortForwarder(); + device.setLogReader(iosApp, deviceLogReader); + + // Start writing messages to the log reader. + Timer.run(() { + deviceLogReader.addLine('Foo'); + deviceLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:456'); + }); + + final FakeShutDownHooks shutDownHooks = FakeShutDownHooks(); + + final LaunchResult launchResult = await device.startApp(iosApp, + prebuiltApplication: true, + debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug, usingCISystem: true), + platformArgs: {}, + shutdownHooks: shutDownHooks, + ); + + expect(launchResult.started, true); + expect(shutDownHooks.hooks.length, 1); + }); + + testUsingContext('IOSDevice.startApp attaches in debug mode via mDNS when device logging fails', () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final FakeProcessManager processManager = FakeProcessManager.empty(); + + final Directory temporaryXcodeProjectDirectory = fileSystem.systemTempDirectory.childDirectory('flutter_empty_xcode.rand0'); + final Directory bundleLocation = fileSystem.currentDirectory; + final IOSDevice device = setUpIOSDevice( + processManager: processManager, + fileSystem: fileSystem, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug( + expectedProject: XcodeDebugProject( + scheme: 'Runner', + xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'), + xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'), + ), + expectedDeviceId: '123', + expectedLaunchArguments: ['--enable-dart-profiling'], + expectedBundlePath: bundleLocation.path, + ) + ); + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + bundleName: 'Runner', + uncompressedBundle: bundleLocation, + applicationPackage: bundleLocation, + ); + final FakeDeviceLogReader deviceLogReader = FakeDeviceLogReader(); + + device.portForwarder = const NoOpDevicePortForwarder(); + device.setLogReader(iosApp, deviceLogReader); + + final LaunchResult launchResult = await device.startApp(iosApp, + prebuiltApplication: true, + debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), + platformArgs: {}, + ); + + expect(launchResult.started, true); + expect(launchResult.hasVmService, true); + expect(await device.stopApp(iosApp), true); + }, overrides: { + MDnsVmServiceDiscovery: () => FakeMDnsVmServiceDiscovery(), + }); + }); + }); } IOSDevice setUpIOSDevice({ @@ -610,6 +821,9 @@ IOSDevice setUpIOSDevice({ ProcessManager? processManager, IOSDeploy? iosDeploy, DeviceConnectionInterface interfaceType = DeviceConnectionInterface.attached, + bool isCoreDevice = false, + IOSCoreDeviceControl? coreDeviceControl, + FakeXcodeDebug? xcodeDebug, }) { final Artifacts artifacts = Artifacts.test(); final FakePlatform macPlatform = FakePlatform( @@ -646,10 +860,13 @@ IOSDevice setUpIOSDevice({ artifacts: artifacts, cache: cache, ), + coreDeviceControl: coreDeviceControl ?? FakeIOSCoreDeviceControl(), + xcodeDebug: xcodeDebug ?? FakeXcodeDebug(), cpuArchitecture: DarwinArch.arm64, connectionInterface: interfaceType, isConnected: true, devModeEnabled: true, + isCoreDevice: isCoreDevice, ); } @@ -669,10 +886,88 @@ class FakeMDnsVmServiceDiscovery extends Fake implements MDnsVmServiceDiscovery Device device, { bool usesIpv6 = false, int? hostVmservicePort, - required int deviceVmservicePort, + int? deviceVmservicePort, bool useDeviceIPAsHost = false, Duration timeout = Duration.zero, }) async { return Uri.tryParse('http://0.0.0.0:1234'); } } + +class FakeXcodeDebug extends Fake implements XcodeDebug { + FakeXcodeDebug({ + this.debugSuccess = true, + this.expectedProject, + this.expectedDeviceId, + this.expectedLaunchArguments, + this.expectedBundlePath, + this.completer, + }); + + final bool debugSuccess; + final XcodeDebugProject? expectedProject; + final String? expectedDeviceId; + final List? expectedLaunchArguments; + final String? expectedBundlePath; + final Completer? completer; + + @override + bool debugStarted = false; + + @override + Future createXcodeProjectWithCustomBundle( + String deviceBundlePath, { + required TemplateRenderer templateRenderer, + Directory? projectDestination, + bool verboseLogging = false, + }) async { + if (expectedBundlePath != null) { + expect(expectedBundlePath, deviceBundlePath); + } + return expectedProject!; + } + + @override + Future debugApp({ + required XcodeDebugProject project, + required String deviceId, + required List launchArguments, + }) async { + if (expectedProject != null) { + expect(project.scheme, expectedProject!.scheme); + expect(project.xcodeWorkspace.path, expectedProject!.xcodeWorkspace.path); + expect(project.xcodeProject.path, expectedProject!.xcodeProject.path); + expect(project.isTemporaryProject, expectedProject!.isTemporaryProject); + } + if (expectedDeviceId != null) { + expect(deviceId, expectedDeviceId); + } + if (expectedLaunchArguments != null) { + expect(expectedLaunchArguments, launchArguments); + } + debugStarted = debugSuccess; + + if (completer != null) { + await completer!.future; + } + return debugSuccess; + } + + @override + Future exit({ + bool force = false, + bool skipDelay = false, + }) async { + return true; + } +} + +class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl {} + +class FakeShutDownHooks extends Fake implements ShutdownHooks { + List hooks = []; + @override + void addShutdownHook(ShutdownHook shutdownHook) { + hooks.add(shutdownHook); + } +} diff --git a/packages/flutter_tools/test/general.shard/ios/xcode_debug_test.dart b/packages/flutter_tools/test/general.shard/ios/xcode_debug_test.dart new file mode 100644 index 00000000000..67aa5838afc --- /dev/null +++ b/packages/flutter_tools/test/general.shard/ios/xcode_debug_test.dart @@ -0,0 +1,1033 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io' as io; + +import 'package:file/memory.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/base/io.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/version.dart'; +import 'package:flutter_tools/src/globals.dart' as globals; +import 'package:flutter_tools/src/ios/xcode_debug.dart'; +import 'package:flutter_tools/src/ios/xcodeproj.dart'; +import 'package:flutter_tools/src/macos/xcode.dart'; +import 'package:test/fake.dart'; + +import '../../src/common.dart'; +import '../../src/context.dart'; +import '../../src/fake_process_manager.dart'; + +void main() { + group('Debug project through Xcode', () { + late MemoryFileSystem fileSystem; + late BufferLogger logger; + late FakeProcessManager fakeProcessManager; + + const String flutterRoot = '/path/to/flutter'; + const String pathToXcodeAutomationScript = '$flutterRoot/packages/flutter_tools/bin/xcode_debug.js'; + + setUp(() { + fileSystem = MemoryFileSystem.test(); + logger = BufferLogger.test(); + fakeProcessManager = FakeProcessManager.empty(); + }); + + group('debugApp', () { + const String pathToXcodeApp = '/Applications/Xcode.app'; + const String deviceId = '0000001234'; + + late Xcode xcode; + late Directory xcodeproj; + late Directory xcworkspace; + late XcodeDebugProject project; + + setUp(() { + xcode = setupXcode( + fakeProcessManager: fakeProcessManager, + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + + xcodeproj = fileSystem.directory('Runner.xcodeproj'); + xcworkspace = fileSystem.directory('Runner.xcworkspace'); + project = XcodeDebugProject( + scheme: 'Runner', + xcodeProject: xcodeproj, + xcodeWorkspace: xcworkspace, + ); + }); + + testWithoutContext('succeeds in opening and debugging with launch options and verbose logging', () async { + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'check-workspace-opened', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--verbose', + ], + stdout: ''' + {"status":false,"errorMessage":"Xcode is not running","debugResult":null} + ''', + ), + FakeCommand( + command: [ + 'open', + '-a', + pathToXcodeApp, + '-g', + '-j', + xcworkspace.path + ], + ), + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'debug', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--device-id', + deviceId, + '--scheme', + project.scheme, + '--skip-building', + '--launch-args', + r'["--enable-dart-profiling","--trace-allowlist=\"foo,bar\""]', + '--verbose', + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":{"completed":false,"status":"running","errorMessage":null}} + ''', + ), + ]); + + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + project = XcodeDebugProject( + scheme: 'Runner', + xcodeProject: xcodeproj, + xcodeWorkspace: xcworkspace, + verboseLogging: true, + ); + + final bool status = await xcodeDebug.debugApp( + project: project, + deviceId: deviceId, + launchArguments: [ + '--enable-dart-profiling', + '--trace-allowlist="foo,bar"' + ], + ); + + expect(logger.errorText, isEmpty); + expect(logger.traceText, contains('Error checking if project opened in Xcode')); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(xcodeDebug.startDebugActionProcess, isNull); + expect(status, true); + }); + + testWithoutContext('succeeds in opening and debugging without launch options and verbose logging', () async { + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'check-workspace-opened', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + ], + stdout: ''' + {"status":false,"errorMessage":"Xcode is not running","debugResult":null} + ''', + ), + FakeCommand( + command: [ + 'open', + '-a', + pathToXcodeApp, + '-g', + '-j', + xcworkspace.path + ], + ), + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'debug', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--device-id', + deviceId, + '--scheme', + project.scheme, + '--skip-building', + '--launch-args', + '[]' + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":{"completed":false,"status":"running","errorMessage":null}} + ''', + ), + ]); + + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + final bool status = await xcodeDebug.debugApp( + project: project, + deviceId: deviceId, + launchArguments: [], + ); + + expect(logger.errorText, isEmpty); + expect(logger.traceText, contains('Error checking if project opened in Xcode')); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(xcodeDebug.startDebugActionProcess, isNull); + expect(status, true); + }); + + testWithoutContext('fails if project fails to open', () async { + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'check-workspace-opened', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + ], + stdout: ''' + {"status":false,"errorMessage":"Xcode is not running","debugResult":null} + ''', + ), + FakeCommand( + command: [ + 'open', + '-a', + pathToXcodeApp, + '-g', + '-j', + xcworkspace.path + ], + exception: ProcessException( + 'open', + [ + '-a', + '/non_existant_path', + '-g', + '-j', + xcworkspace.path, + ], + 'The application /non_existant_path cannot be opened for an unexpected reason', + ), + ), + ]); + + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + final bool status = await xcodeDebug.debugApp( + project: project, + deviceId: deviceId, + launchArguments: [ + '--enable-dart-profiling', + '--trace-allowlist="foo,bar"', + ], + ); + + expect( + logger.errorText, + contains('The application /non_existant_path cannot be opened for an unexpected reason'), + ); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(status, false); + }); + + testWithoutContext('fails if osascript errors', () async { + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'check-workspace-opened', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + ], + stdout: ''' + {"status":true,"errorMessage":"","debugResult":null} + ''', + ), + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'debug', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--device-id', + deviceId, + '--scheme', + project.scheme, + '--skip-building', + '--launch-args', + r'["--enable-dart-profiling","--trace-allowlist=\"foo,bar\""]' + ], + exitCode: 1, + stderr: "/flutter/packages/flutter_tools/bin/xcode_debug.js: execution error: Error: ReferenceError: Can't find variable: y (-2700)", + ), + ]); + + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + final bool status = await xcodeDebug.debugApp( + project: project, + deviceId: deviceId, + launchArguments: [ + '--enable-dart-profiling', + '--trace-allowlist="foo,bar"', + ], + ); + + expect(logger.errorText, contains('Error executing osascript')); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(status, false); + }); + + testWithoutContext('fails if osascript output returns false status', () async { + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'check-workspace-opened', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":null} + ''', + ), + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'debug', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--device-id', + deviceId, + '--scheme', + project.scheme, + '--skip-building', + '--launch-args', + r'["--enable-dart-profiling","--trace-allowlist=\"foo,bar\""]' + ], + stdout: ''' + {"status":false,"errorMessage":"Unable to find target device.","debugResult":null} + ''', + ), + ]); + + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + final bool status = await xcodeDebug.debugApp( + project: project, + deviceId: deviceId, + launchArguments: [ + '--enable-dart-profiling', + '--trace-allowlist="foo,bar"', + ], + ); + + expect( + logger.errorText, + contains('Error starting debug session in Xcode'), + ); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(status, false); + }); + + testWithoutContext('fails if missing debug results', () async { + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'check-workspace-opened', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":null} + ''', + ), + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'debug', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--device-id', + deviceId, + '--scheme', + project.scheme, + '--skip-building', + '--launch-args', + r'["--enable-dart-profiling","--trace-allowlist=\"foo,bar\""]' + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":null} + ''', + ), + ]); + + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + final bool status = await xcodeDebug.debugApp( + project: project, + deviceId: deviceId, + launchArguments: [ + '--enable-dart-profiling', + '--trace-allowlist="foo,bar"' + ], + ); + + expect( + logger.errorText, + contains('Unable to get debug results from response'), + ); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(status, false); + }); + + testWithoutContext('fails if debug results status is not running', () async { + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'check-workspace-opened', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":null} + ''', + ), + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'debug', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--device-id', + deviceId, + '--scheme', + project.scheme, + '--skip-building', + '--launch-args', + r'["--enable-dart-profiling","--trace-allowlist=\"foo,bar\""]' + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":{"completed":false,"status":"not yet started","errorMessage":null}} + ''', + ), + ]); + + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + final bool status = await xcodeDebug.debugApp( + project: project, + deviceId: deviceId, + launchArguments: [ + '--enable-dart-profiling', + '--trace-allowlist="foo,bar"', + ], + ); + + expect(logger.errorText, contains('Unexpected debug results')); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(status, false); + }); + }); + + group('parse script response', () { + testWithoutContext('fails if osascript output returns non-json output', () async { + final Xcode xcode = setupXcode( + fakeProcessManager: FakeProcessManager.any(), + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + final XcodeAutomationScriptResponse? response = xcodeDebug.parseScriptResponse('not json'); + + expect( + logger.errorText, + contains('osascript returned non-JSON response'), + ); + expect(response, isNull); + }); + + testWithoutContext('fails if osascript output returns unexpected json', () async { + final Xcode xcode = setupXcode( + fakeProcessManager: FakeProcessManager.any(), + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + final XcodeAutomationScriptResponse? response = xcodeDebug.parseScriptResponse('[]'); + + expect( + logger.errorText, + contains('osascript returned unexpected JSON response'), + ); + expect(response, isNull); + }); + + testWithoutContext('fails if osascript output is missing status field', () async { + final Xcode xcode = setupXcode( + fakeProcessManager: FakeProcessManager.any(), + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + + final XcodeAutomationScriptResponse? response = xcodeDebug.parseScriptResponse('{}'); + + expect( + logger.errorText, + contains('osascript returned unexpected JSON response'), + ); + expect(response, isNull); + }); + }); + + group('exit', () { + const String pathToXcodeApp = '/Applications/Xcode.app'; + + late Directory projectDirectory; + late Directory xcodeproj; + late Directory xcworkspace; + + setUp(() { + projectDirectory = fileSystem.directory('FlutterApp'); + xcodeproj = projectDirectory.childDirectory('Runner.xcodeproj'); + xcworkspace = projectDirectory.childDirectory('Runner.xcworkspace'); + }); + + testWithoutContext('exits when waiting for debug session to start', () async { + final Xcode xcode = setupXcode( + fakeProcessManager: fakeProcessManager, + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + final XcodeDebugProject project = XcodeDebugProject( + scheme: 'Runner', + xcodeProject: xcodeproj, + xcodeWorkspace: xcworkspace, + ); + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'stop', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":null} + ''', + ), + ]); + + xcodeDebug.startDebugActionProcess = FakeProcess(); + xcodeDebug.currentDebuggingProject = project; + + expect(xcodeDebug.startDebugActionProcess, isNotNull); + expect(xcodeDebug.currentDebuggingProject, isNotNull); + + final bool exitStatus = await xcodeDebug.exit(); + + expect((xcodeDebug.startDebugActionProcess! as FakeProcess).killed, isTrue); + expect(xcodeDebug.currentDebuggingProject, isNull); + expect(logger.errorText, isEmpty); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(exitStatus, isTrue); + }); + + testWithoutContext('exits and deletes temporary directory', () async { + final Xcode xcode = setupXcode( + fakeProcessManager: fakeProcessManager, + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + xcodeproj.createSync(recursive: true); + xcworkspace.createSync(recursive: true); + + final XcodeDebugProject project = XcodeDebugProject( + scheme: 'Runner', + xcodeProject: xcodeproj, + xcodeWorkspace: xcworkspace, + isTemporaryProject: true, + ); + + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'stop', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--close-window' + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":null} + ''', + ), + ]); + + xcodeDebug.startDebugActionProcess = FakeProcess(); + xcodeDebug.currentDebuggingProject = project; + + expect(xcodeDebug.startDebugActionProcess, isNotNull); + expect(xcodeDebug.currentDebuggingProject, isNotNull); + expect(projectDirectory.existsSync(), isTrue); + expect(xcodeproj.existsSync(), isTrue); + expect(xcworkspace.existsSync(), isTrue); + + final bool status = await xcodeDebug.exit(skipDelay: true); + + expect((xcodeDebug.startDebugActionProcess! as FakeProcess).killed, isTrue); + expect(xcodeDebug.currentDebuggingProject, isNull); + expect(projectDirectory.existsSync(), isFalse); + expect(xcodeproj.existsSync(), isFalse); + expect(xcworkspace.existsSync(), isFalse); + expect(logger.errorText, isEmpty); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(status, isTrue); + }); + + testWithoutContext('kill Xcode when force exit', () async { + final Xcode xcode = setupXcode( + fakeProcessManager: FakeProcessManager.any(), + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + final XcodeDebugProject project = XcodeDebugProject( + scheme: 'Runner', + xcodeProject: xcodeproj, + xcodeWorkspace: xcworkspace, + ); + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + fakeProcessManager.addCommands([ + const FakeCommand( + command: [ + 'killall', + '-9', + 'Xcode', + ], + ), + ]); + + xcodeDebug.startDebugActionProcess = FakeProcess(); + xcodeDebug.currentDebuggingProject = project; + + expect(xcodeDebug.startDebugActionProcess, isNotNull); + expect(xcodeDebug.currentDebuggingProject, isNotNull); + + final bool exitStatus = await xcodeDebug.exit(force: true); + + expect((xcodeDebug.startDebugActionProcess! as FakeProcess).killed, isTrue); + expect(xcodeDebug.currentDebuggingProject, isNull); + expect(logger.errorText, isEmpty); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(exitStatus, isTrue); + }); + }); + + group('stop app', () { + const String pathToXcodeApp = '/Applications/Xcode.app'; + + late Xcode xcode; + late Directory xcodeproj; + late Directory xcworkspace; + late XcodeDebugProject project; + + setUp(() { + xcode = setupXcode( + fakeProcessManager: fakeProcessManager, + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + xcodeproj = fileSystem.directory('Runner.xcodeproj'); + xcworkspace = fileSystem.directory('Runner.xcworkspace'); + project = XcodeDebugProject( + scheme: 'Runner', + xcodeProject: xcodeproj, + xcodeWorkspace: xcworkspace, + ); + }); + + testWithoutContext('succeeds with all optional flags', () async { + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'stop', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--close-window', + '--prompt-to-save' + ], + stdout: ''' + {"status":true,"errorMessage":null,"debugResult":null} + ''', + ), + ]); + + final bool status = await xcodeDebug.stopDebuggingApp( + project: project, + closeXcode: true, + promptToSaveOnClose: true, + ); + + expect(logger.errorText, isEmpty); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(status, isTrue); + }); + + testWithoutContext('fails if osascript output returns false status', () async { + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: fileSystem, + ); + fakeProcessManager.addCommands([ + FakeCommand( + command: [ + 'xcrun', + 'osascript', + '-l', + 'JavaScript', + pathToXcodeAutomationScript, + 'stop', + '--xcode-path', + pathToXcodeApp, + '--project-path', + project.xcodeProject.path, + '--workspace-path', + project.xcodeWorkspace.path, + '--close-window', + '--prompt-to-save' + ], + stdout: ''' + {"status":false,"errorMessage":"Failed to stop app","debugResult":null} + ''', + ), + ]); + + final bool status = await xcodeDebug.stopDebuggingApp( + project: project, + closeXcode: true, + promptToSaveOnClose: true, + ); + + expect(logger.errorText, contains('Error stopping app in Xcode')); + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(status, isFalse); + }); + }); + }); + + group('Debug project through Xcode with app bundle', () { + late BufferLogger logger; + late FakeProcessManager fakeProcessManager; + late MemoryFileSystem fileSystem; + + const String flutterRoot = '/path/to/flutter'; + + setUp(() { + logger = BufferLogger.test(); + fakeProcessManager = FakeProcessManager.empty(); + fileSystem = MemoryFileSystem.test(); + }); + + testUsingContext('creates temporary xcode project', () async { + final Xcode xcode = setupXcode( + fakeProcessManager: fakeProcessManager, + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + + final XcodeDebug xcodeDebug = XcodeDebug( + logger: logger, + processManager: fakeProcessManager, + xcode: xcode, + fileSystem: globals.fs, + ); + + final Directory projectDirectory = globals.fs.systemTempDirectory.createTempSync('flutter_empty_xcode.'); + + try { + final XcodeDebugProject project = await xcodeDebug.createXcodeProjectWithCustomBundle( + '/path/to/bundle', + templateRenderer: globals.templateRenderer, + projectDestination: projectDirectory, + ); + + final File schemeFile = projectDirectory + .childDirectory('Runner.xcodeproj') + .childDirectory('xcshareddata') + .childDirectory('xcschemes') + .childFile('Runner.xcscheme'); + + expect(project.scheme, 'Runner'); + expect(project.xcodeProject.existsSync(), isTrue); + expect(project.xcodeWorkspace.existsSync(), isTrue); + expect(project.isTemporaryProject, isTrue); + expect(projectDirectory.childDirectory('Runner.xcodeproj').existsSync(), isTrue); + expect(projectDirectory.childDirectory('Runner.xcworkspace').existsSync(), isTrue); + expect(schemeFile.existsSync(), isTrue); + expect(schemeFile.readAsStringSync(), contains('FilePath = "/path/to/bundle"')); + + } catch (err) { // ignore: avoid_catches_without_on_clauses + fail(err.toString()); + } finally { + projectDirectory.deleteSync(recursive: true); + } + }); + }); +} + +Xcode setupXcode({ + required FakeProcessManager fakeProcessManager, + required FileSystem fileSystem, + required String flutterRoot, + bool xcodeSelect = true, +}) { + fakeProcessManager.addCommand(const FakeCommand( + command: ['/usr/bin/xcode-select', '--print-path'], + stdout: '/Applications/Xcode.app/Contents/Developer', + )); + + fileSystem.file('$flutterRoot/packages/flutter_tools/bin/xcode_debug.js').createSync(recursive: true); + + final XcodeProjectInterpreter xcodeProjectInterpreter = XcodeProjectInterpreter.test( + processManager: FakeProcessManager.any(), + version: Version(14, 0, 0), + ); + + return Xcode.test( + processManager: fakeProcessManager, + xcodeProjectInterpreter: xcodeProjectInterpreter, + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); +} + +class FakeProcess extends Fake implements Process { + bool killed = false; + + @override + bool kill([io.ProcessSignal signal = io.ProcessSignal.sigterm]) { + killed = true; + return true; + } +} diff --git a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart index 90210c32556..3dfe9157d51 100644 --- a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart @@ -1306,5 +1306,75 @@ flutter: expectedBuildNumber: '1', ); }); + + group('CoreDevice', () { + testUsingContext('sets BUILD_DIR for core devices in debug mode', () async { + const BuildInfo buildInfo = BuildInfo.debug; + final FlutterProject project = FlutterProject.fromDirectoryTest(fs.directory('path/to/project')); + await updateGeneratedXcodeProperties( + project: project, + buildInfo: buildInfo, + useMacOSConfig: true, + usingCoreDevice: true, + ); + + final File config = fs.file('path/to/project/macos/Flutter/ephemeral/Flutter-Generated.xcconfig'); + expect(config.existsSync(), isTrue); + + final String contents = config.readAsStringSync(); + expect(contents, contains('\nBUILD_DIR=/build/ios\n')); + }, overrides: { + Artifacts: () => localIosArtifacts, + Platform: () => macOS, + FileSystem: () => fs, + ProcessManager: () => FakeProcessManager.any(), + XcodeProjectInterpreter: () => xcodeProjectInterpreter, + }); + + testUsingContext('does not set BUILD_DIR for core devices in release mode', () async { + const BuildInfo buildInfo = BuildInfo.release; + final FlutterProject project = FlutterProject.fromDirectoryTest(fs.directory('path/to/project')); + await updateGeneratedXcodeProperties( + project: project, + buildInfo: buildInfo, + useMacOSConfig: true, + usingCoreDevice: true, + ); + + final File config = fs.file('path/to/project/macos/Flutter/ephemeral/Flutter-Generated.xcconfig'); + expect(config.existsSync(), isTrue); + + final String contents = config.readAsStringSync(); + expect(contents.contains('\nBUILD_DIR'), isFalse); + }, overrides: { + Artifacts: () => localIosArtifacts, + Platform: () => macOS, + FileSystem: () => fs, + ProcessManager: () => FakeProcessManager.any(), + XcodeProjectInterpreter: () => xcodeProjectInterpreter, + }); + + testUsingContext('does not set BUILD_DIR for non core devices', () async { + const BuildInfo buildInfo = BuildInfo.debug; + final FlutterProject project = FlutterProject.fromDirectoryTest(fs.directory('path/to/project')); + await updateGeneratedXcodeProperties( + project: project, + buildInfo: buildInfo, + useMacOSConfig: true, + ); + + final File config = fs.file('path/to/project/macos/Flutter/ephemeral/Flutter-Generated.xcconfig'); + expect(config.existsSync(), isTrue); + + final String contents = config.readAsStringSync(); + expect(contents.contains('\nBUILD_DIR'), isFalse); + }, overrides: { + Artifacts: () => localIosArtifacts, + Platform: () => macOS, + FileSystem: () => fs, + ProcessManager: () => FakeProcessManager.any(), + XcodeProjectInterpreter: () => xcodeProjectInterpreter, + }); + }); }); } diff --git a/packages/flutter_tools/test/general.shard/macos/xcode_test.dart b/packages/flutter_tools/test/general.shard/macos/xcode_test.dart index 887712ad2e5..6df6b82b6d7 100644 --- a/packages/flutter_tools/test/general.shard/macos/xcode_test.dart +++ b/packages/flutter_tools/test/general.shard/macos/xcode_test.dart @@ -4,16 +4,20 @@ import 'dart:async'; +import 'package:file/memory.dart'; import 'package:flutter_tools/src/artifacts.dart'; import 'package:flutter_tools/src/base/io.dart' show ProcessException; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/platform.dart'; +import 'package:flutter_tools/src/base/user_messages.dart'; import 'package:flutter_tools/src/base/version.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/device.dart'; +import 'package:flutter_tools/src/ios/core_devices.dart'; import 'package:flutter_tools/src/ios/devices.dart'; import 'package:flutter_tools/src/ios/iproxy.dart'; +import 'package:flutter_tools/src/ios/xcode_debug.dart'; import 'package:flutter_tools/src/ios/xcodeproj.dart'; import 'package:flutter_tools/src/macos/xcdevice.dart'; import 'package:flutter_tools/src/macos/xcode.dart'; @@ -75,7 +79,7 @@ void main() { expect(fakeProcessManager, hasNoRemainingExpectations); }); - testWithoutContext('isSimctlInstalled is true when simctl list fails', () { + testWithoutContext('isSimctlInstalled is false when simctl list fails', () { fakeProcessManager.addCommand( const FakeCommand( command: [ @@ -97,6 +101,156 @@ void main() { expect(fakeProcessManager, hasNoRemainingExpectations); }); + group('isDevicectlInstalled', () { + testWithoutContext('is true when Xcode is 15+ and devicectl succeeds', () { + fakeProcessManager.addCommand( + const FakeCommand( + command: [ + 'xcrun', + 'devicectl', + '--version', + ], + ), + ); + xcodeProjectInterpreter.version = Version(15, 0, 0); + final Xcode xcode = Xcode.test( + processManager: fakeProcessManager, + xcodeProjectInterpreter: xcodeProjectInterpreter, + ); + + expect(xcode.isDevicectlInstalled, isTrue); + expect(fakeProcessManager, hasNoRemainingExpectations); + }); + + testWithoutContext('is false when devicectl fails', () { + fakeProcessManager.addCommand( + const FakeCommand( + command: [ + 'xcrun', + 'devicectl', + '--version', + ], + exitCode: 1, + ), + ); + xcodeProjectInterpreter.version = Version(15, 0, 0); + final Xcode xcode = Xcode.test( + processManager: fakeProcessManager, + xcodeProjectInterpreter: xcodeProjectInterpreter, + ); + + expect(xcode.isDevicectlInstalled, isFalse); + expect(fakeProcessManager, hasNoRemainingExpectations); + }); + + testWithoutContext('is false when Xcode is less than 15', () { + xcodeProjectInterpreter.version = Version(14, 0, 0); + final Xcode xcode = Xcode.test( + processManager: fakeProcessManager, + xcodeProjectInterpreter: xcodeProjectInterpreter, + ); + + expect(xcode.isDevicectlInstalled, isFalse); + expect(fakeProcessManager, hasNoRemainingExpectations); + }); + }); + + group('pathToXcodeApp', () { + late UserMessages userMessages; + + setUp(() { + userMessages = UserMessages(); + }); + + testWithoutContext('parses correctly', () { + final Xcode xcode = Xcode.test( + processManager: fakeProcessManager, + xcodeProjectInterpreter: xcodeProjectInterpreter, + ); + + fakeProcessManager.addCommand(const FakeCommand( + command: ['/usr/bin/xcode-select', '--print-path'], + stdout: '/Applications/Xcode.app/Contents/Developer', + )); + + expect(xcode.xcodeAppPath, '/Applications/Xcode.app'); + expect(fakeProcessManager, hasNoRemainingExpectations); + }); + + testWithoutContext('throws error if not found', () { + final Xcode xcode = Xcode.test( + processManager: FakeProcessManager.any(), + xcodeProjectInterpreter: xcodeProjectInterpreter, + ); + + expect( + () => xcode.xcodeAppPath, + throwsToolExit(message: userMessages.xcodeMissing), + ); + }); + + testWithoutContext('throws error with unexpected outcome', () { + final Xcode xcode = Xcode.test( + processManager: fakeProcessManager, + xcodeProjectInterpreter: xcodeProjectInterpreter, + ); + + fakeProcessManager.addCommand(const FakeCommand( + command: [ + '/usr/bin/xcode-select', + '--print-path', + ], + stdout: '/Library/Developer/CommandLineTools', + )); + + expect( + () => xcode.xcodeAppPath, + throwsToolExit(message: userMessages.xcodeMissing), + ); + expect(fakeProcessManager, hasNoRemainingExpectations); + }); + }); + + group('pathToXcodeAutomationScript', () { + const String flutterRoot = '/path/to/flutter'; + + late MemoryFileSystem fileSystem; + + setUp(() { + fileSystem = MemoryFileSystem.test(); + }); + + testWithoutContext('returns path when file is found', () { + final Xcode xcode = Xcode.test( + processManager: fakeProcessManager, + xcodeProjectInterpreter: xcodeProjectInterpreter, + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + + fileSystem.file('$flutterRoot/packages/flutter_tools/bin/xcode_debug.js').createSync(recursive: true); + + expect( + xcode.xcodeAutomationScriptPath, + '$flutterRoot/packages/flutter_tools/bin/xcode_debug.js', + ); + }); + + testWithoutContext('throws error when not found', () { + final Xcode xcode = Xcode.test( + processManager: fakeProcessManager, + xcodeProjectInterpreter: xcodeProjectInterpreter, + fileSystem: fileSystem, + flutterRoot: flutterRoot, + ); + + expect(() => + xcode.xcodeAutomationScriptPath, + throwsToolExit() + ); + }); + }); + group('macOS', () { late Xcode xcode; late BufferLogger logger; @@ -339,6 +493,7 @@ void main() { group('xcdevice not installed', () { late XCDevice xcdevice; late Xcode xcode; + late MemoryFileSystem fileSystem; setUp(() { xcode = Xcode.test( @@ -348,6 +503,7 @@ void main() { version: null, // Not installed. ), ); + fileSystem = MemoryFileSystem.test(); xcdevice = XCDevice( processManager: fakeProcessManager, logger: logger, @@ -356,6 +512,9 @@ void main() { artifacts: Artifacts.test(), cache: Cache.test(processManager: FakeProcessManager.any()), iproxy: IProxy.test(logger: logger, processManager: fakeProcessManager), + fileSystem: fileSystem, + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug(), ); }); @@ -373,9 +532,13 @@ void main() { group('xcdevice', () { late XCDevice xcdevice; late Xcode xcode; + late MemoryFileSystem fileSystem; + late FakeIOSCoreDeviceControl coreDeviceControl; setUp(() { xcode = Xcode.test(processManager: FakeProcessManager.any()); + fileSystem = MemoryFileSystem.test(); + coreDeviceControl = FakeIOSCoreDeviceControl(); xcdevice = XCDevice( processManager: fakeProcessManager, logger: logger, @@ -384,6 +547,9 @@ void main() { artifacts: Artifacts.test(), cache: Cache.test(processManager: FakeProcessManager.any()), iproxy: IProxy.test(logger: logger, processManager: fakeProcessManager), + fileSystem: fileSystem, + coreDeviceControl: coreDeviceControl, + xcodeDebug: FakeXcodeDebug(), ); }); @@ -1117,6 +1283,176 @@ void main() { }, overrides: { Platform: () => macPlatform, }); + + group('with CoreDevices', () { + testUsingContext('returns devices with corresponding CoreDevices', () async { + const String devicesOutput = ''' +[ + { + "simulator" : true, + "operatingSystemVersion" : "13.3 (17K446)", + "available" : true, + "platform" : "com.apple.platform.appletvsimulator", + "modelCode" : "AppleTV5,3", + "identifier" : "CBB5E1ED-2172-446E-B4E7-F2B5823DBBA6", + "architecture" : "x86_64", + "modelName" : "Apple TV", + "name" : "Apple TV" + }, + { + "simulator" : false, + "operatingSystemVersion" : "13.3 (17C54)", + "interface" : "usb", + "available" : true, + "platform" : "com.apple.platform.iphoneos", + "modelCode" : "iPhone8,1", + "identifier" : "00008027-00192736010F802E", + "architecture" : "arm64", + "modelName" : "iPhone 6s", + "name" : "An iPhone (Space Gray)" + }, + { + "simulator" : false, + "operatingSystemVersion" : "10.1 (14C54)", + "interface" : "usb", + "available" : true, + "platform" : "com.apple.platform.iphoneos", + "modelCode" : "iPad11,4", + "identifier" : "98206e7a4afd4aedaff06e687594e089dede3c44", + "architecture" : "armv7", + "modelName" : "iPad Air 3rd Gen", + "name" : "iPad 1" + }, + { + "simulator" : false, + "operatingSystemVersion" : "10.1 (14C54)", + "interface" : "network", + "available" : true, + "platform" : "com.apple.platform.iphoneos", + "modelCode" : "iPad11,4", + "identifier" : "234234234234234234345445687594e089dede3c44", + "architecture" : "arm64", + "modelName" : "iPad Air 3rd Gen", + "name" : "A networked iPad" + }, + { + "simulator" : false, + "operatingSystemVersion" : "10.1 (14C54)", + "interface" : "usb", + "available" : true, + "platform" : "com.apple.platform.iphoneos", + "modelCode" : "iPad11,4", + "identifier" : "f577a7903cc54959be2e34bc4f7f80b7009efcf4", + "architecture" : "BOGUS", + "modelName" : "iPad Air 3rd Gen", + "name" : "iPad 2" + }, + { + "simulator" : true, + "operatingSystemVersion" : "6.1.1 (17S445)", + "available" : true, + "platform" : "com.apple.platform.watchsimulator", + "modelCode" : "Watch5,4", + "identifier" : "2D74FB11-88A0-44D0-B81E-C0C142B1C94A", + "architecture" : "i386", + "modelName" : "Apple Watch Series 5 - 44mm", + "name" : "Apple Watch Series 5 - 44mm" + }, + { + "simulator" : false, + "operatingSystemVersion" : "13.3 (17C54)", + "interface" : "usb", + "available" : false, + "platform" : "com.apple.platform.iphoneos", + "modelCode" : "iPhone8,1", + "identifier" : "c4ca6f7a53027d1b7e4972e28478e7a28e2faee2", + "architecture" : "arm64", + "modelName" : "iPhone 6s", + "name" : "iPhone", + "error" : { + "code" : -9, + "failureReason" : "", + "description" : "iPhone is not paired with your computer.", + "domain" : "com.apple.platform.iphoneos" + } + } +] +'''; + coreDeviceControl.devices.addAll([ + FakeIOSCoreDevice( + udid: '00008027-00192736010F802E', + connectionInterface: DeviceConnectionInterface.wireless, + developerModeStatus: 'enabled', + ), + FakeIOSCoreDevice( + connectionInterface: DeviceConnectionInterface.wireless, + developerModeStatus: 'enabled', + ), + FakeIOSCoreDevice( + udid: '234234234234234234345445687594e089dede3c44', + connectionInterface: DeviceConnectionInterface.attached, + ), + FakeIOSCoreDevice( + udid: 'f577a7903cc54959be2e34bc4f7f80b7009efcf4', + connectionInterface: DeviceConnectionInterface.attached, + developerModeStatus: 'disabled', + ), + ]); + + fakeProcessManager.addCommand(const FakeCommand( + command: ['xcrun', 'xcdevice', 'list', '--timeout', '2'], + stdout: devicesOutput, + )); + + final List devices = await xcdevice.getAvailableIOSDevices(); + expect(devices, hasLength(5)); + expect(devices[0].id, '00008027-00192736010F802E'); + expect(devices[0].name, 'An iPhone (Space Gray)'); + expect(await devices[0].sdkNameAndVersion, 'iOS 13.3 17C54'); + expect(devices[0].cpuArchitecture, DarwinArch.arm64); + expect(devices[0].connectionInterface, DeviceConnectionInterface.wireless); + expect(devices[0].isConnected, true); + expect(devices[0].devModeEnabled, true); + + expect(devices[1].id, '98206e7a4afd4aedaff06e687594e089dede3c44'); + expect(devices[1].name, 'iPad 1'); + expect(await devices[1].sdkNameAndVersion, 'iOS 10.1 14C54'); + expect(devices[1].cpuArchitecture, DarwinArch.armv7); + expect(devices[1].connectionInterface, DeviceConnectionInterface.attached); + expect(devices[1].isConnected, true); + expect(devices[1].devModeEnabled, true); + + expect(devices[2].id, '234234234234234234345445687594e089dede3c44'); + expect(devices[2].name, 'A networked iPad'); + expect(await devices[2].sdkNameAndVersion, 'iOS 10.1 14C54'); + expect(devices[2].cpuArchitecture, DarwinArch.arm64); // Defaults to arm64 for unknown architecture. + expect(devices[2].connectionInterface, DeviceConnectionInterface.attached); + expect(devices[2].isConnected, true); + expect(devices[2].devModeEnabled, false); + + expect(devices[3].id, 'f577a7903cc54959be2e34bc4f7f80b7009efcf4'); + expect(devices[3].name, 'iPad 2'); + expect(await devices[3].sdkNameAndVersion, 'iOS 10.1 14C54'); + expect(devices[3].cpuArchitecture, DarwinArch.arm64); // Defaults to arm64 for unknown architecture. + expect(devices[3].connectionInterface, DeviceConnectionInterface.attached); + expect(devices[3].isConnected, true); + expect(devices[3].devModeEnabled, false); + + expect(devices[4].id, 'c4ca6f7a53027d1b7e4972e28478e7a28e2faee2'); + expect(devices[4].name, 'iPhone'); + expect(await devices[4].sdkNameAndVersion, 'iOS 13.3 17C54'); + expect(devices[4].cpuArchitecture, DarwinArch.arm64); + expect(devices[4].connectionInterface, DeviceConnectionInterface.attached); + expect(devices[4].isConnected, false); + expect(devices[4].devModeEnabled, true); + + expect(fakeProcessManager, hasNoRemainingExpectations); + }, overrides: { + Platform: () => macPlatform, + Artifacts: () => Artifacts.test(), + }); + + }); }); group('diagnostics', () { @@ -1312,3 +1648,41 @@ class FakeXcodeProjectInterpreter extends Fake implements XcodeProjectInterprete @override List xcrunCommand() => ['xcrun']; } + +class FakeXcodeDebug extends Fake implements XcodeDebug {} + +class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl { + + List devices = []; + + @override + Future> getCoreDevices({Duration timeout = Duration.zero}) async { + return devices; + } +} + +class FakeIOSCoreDevice extends Fake implements IOSCoreDevice { + FakeIOSCoreDevice({ + this.udid, + this.connectionInterface, + this.developerModeStatus, + }); + + final String? developerModeStatus; + + @override + final String? udid; + + @override + final DeviceConnectionInterface? connectionInterface; + + @override + IOSCoreDeviceProperties? get deviceProperties => FakeIOSCoreDeviceProperties(developerModeStatus: developerModeStatus); +} + +class FakeIOSCoreDeviceProperties extends Fake implements IOSCoreDeviceProperties { + FakeIOSCoreDeviceProperties({required this.developerModeStatus}); + + @override + final String? developerModeStatus; +} diff --git a/packages/flutter_tools/test/general.shard/mdns_discovery_test.dart b/packages/flutter_tools/test/general.shard/mdns_discovery_test.dart index b0f60853875..8c686851213 100644 --- a/packages/flutter_tools/test/general.shard/mdns_discovery_test.dart +++ b/packages/flutter_tools/test/general.shard/mdns_discovery_test.dart @@ -478,6 +478,18 @@ void main() { }); group('for launch', () { + testWithoutContext('Ensure either port or device name are provided', () async { + final MDnsClient client = FakeMDnsClient([], >{}); + + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + + expect(() async => portDiscovery.queryForLaunch(applicationId: 'app-id'), throwsAssertionError); + }); + testWithoutContext('No ports available', () async { final MDnsClient client = FakeMDnsClient([], >{}); @@ -666,6 +678,93 @@ void main() { message:'Did not find a Dart VM Service advertised for srv-bar on port 321.'), ); }); + + testWithoutContext('Matches on application id and device name', () async { + final MDnsClient client = FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'srv-foo'), + PtrResourceRecord('bar', future, domainName: 'srv-bar'), + PtrResourceRecord('baz', future, domainName: 'srv-boo'), + ], + >{ + 'srv-bar': [ + SrvResourceRecord('srv-foo', future, port: 123, weight: 1, priority: 1, target: 'My-Phone.local'), + ], + }, + ); + final FakeIOSDevice device = FakeIOSDevice( + name: 'My Phone', + ); + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + + final Uri? uri = await portDiscovery.getVMServiceUriForLaunch( + 'srv-bar', + device, + ); + expect(uri.toString(), 'http://127.0.0.1:123/'); + }); + + testWithoutContext('Throw error if unable to find VM Service with app id and device name', () async { + final MDnsClient client = FakeMDnsClient( + [ + PtrResourceRecord('foo', future, domainName: 'srv-foo'), + PtrResourceRecord('bar', future, domainName: 'srv-bar'), + PtrResourceRecord('baz', future, domainName: 'srv-boo'), + ], + >{ + 'srv-foo': [ + SrvResourceRecord('srv-foo', future, port: 123, weight: 1, priority: 1, target: 'target-foo'), + ], + }, + ); + final FakeIOSDevice device = FakeIOSDevice( + name: 'My Phone', + ); + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: client, + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + expect( + portDiscovery.getVMServiceUriForLaunch( + 'srv-bar', + device, + ), + throwsToolExit( + message:'Did not find a Dart VM Service advertised for srv-bar'), + ); + }); + }); + + group('deviceNameMatchesTargetName', () { + testWithoutContext('compares case insensitive and without spaces, hypthens, .local', () { + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: FakeMDnsClient( + [], + >{}, + ), + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + + expect(portDiscovery.deviceNameMatchesTargetName('My phone', 'My-Phone.local'), isTrue); + }); + + testWithoutContext('includes numbers in comparison', () { + final MDnsVmServiceDiscovery portDiscovery = MDnsVmServiceDiscovery( + mdnsClient: FakeMDnsClient( + [], + >{}, + ), + logger: BufferLogger.test(), + flutterUsage: TestUsage(), + ); + expect(portDiscovery.deviceNameMatchesTargetName('My phone', 'My-Phone-2.local'), isFalse); + }); }); testWithoutContext('Find firstMatchingVmService with many available and no application id', () async { @@ -895,6 +994,11 @@ class FakeMDnsClient extends Fake implements MDnsClient { // Until we fix that, we have to also ignore related lints here. // ignore: avoid_implementing_value_types class FakeIOSDevice extends Fake implements IOSDevice { + FakeIOSDevice({this.name = 'iPhone'}); + + @override + final String name; + @override Future get targetPlatform async => TargetPlatform.ios;