1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'package:xml/xml.dart';
6import 'package:xml/xpath.dart';
7
8import '../base/file_system.dart';
9import '../base/project_migrator.dart';
10import '../build_info.dart';
11import '../ios/xcodeproj.dart';
12import '../project.dart';
13
14class LLDBInitMigration extends ProjectMigrator {
15 LLDBInitMigration(
16 IosProject project,
17 BuildInfo buildInfo,
18 super.logger, {
19 required FileSystem fileSystem,
20 required EnvironmentType environmentType,
21 String? deviceID,
22 }) : _xcodeProject = project,
23 _buildInfo = buildInfo,
24 _xcodeProjectInfoFile = project.xcodeProjectInfoFile,
25 _fileSystem = fileSystem,
26 _environmentType = environmentType,
27 _deviceID = deviceID;
28
29 final IosProject _xcodeProject;
30 final BuildInfo _buildInfo;
31 final FileSystem _fileSystem;
32 final File _xcodeProjectInfoFile;
33 final EnvironmentType _environmentType;
34 final String? _deviceID;
35
36 String get _initPath =>
37 _xcodeProject.lldbInitFile.path.replaceFirst(_xcodeProject.hostAppRoot.path, r'$(SRCROOT)');
38
39 static const _launchActionIdentifier = 'LaunchAction';
40 static const _testActionIdentifier = 'TestAction';
41
42 @override
43 Future<void> migrate() async {
44 SchemeInfo? schemeInfo;
45 try {
46 if (!_xcodeProjectInfoFile.existsSync()) {
47 throw Exception('Xcode project not found.');
48 }
49
50 schemeInfo = await _getSchemeInfo();
51
52 final bool isSchemeMigrated = await _isSchemeMigrated(schemeInfo);
53 if (isSchemeMigrated) {
54 return;
55 }
56 _migrateScheme(schemeInfo);
57 } on Exception catch (e) {
58 logger.printError(
59 'An error occurred when adding LLDB Init File:\n'
60 '$e',
61 );
62 }
63 }
64
65 Future<SchemeInfo> _getSchemeInfo() async {
66 final XcodeProjectInfo? projectInfo = await _xcodeProject.projectInfo();
67 if (projectInfo == null) {
68 throw Exception('Unable to get Xcode project info.');
69 }
70 if (_xcodeProject.xcodeWorkspace == null) {
71 throw Exception('Xcode workspace not found.');
72 }
73 final String? scheme = projectInfo.schemeFor(_buildInfo);
74 if (scheme == null) {
75 projectInfo.reportFlavorNotFoundAndExit();
76 }
77
78 final File schemeFile = _xcodeProject.xcodeProjectSchemeFile(scheme: scheme);
79 if (!schemeFile.existsSync()) {
80 throw Exception('Unable to get scheme file for $scheme.');
81 }
82
83 final String schemeContent = schemeFile.readAsStringSync();
84 return SchemeInfo(schemeName: scheme, schemeFile: schemeFile, schemeContent: schemeContent);
85 }
86
87 Future<bool> _isSchemeMigrated(SchemeInfo schemeInfo) async {
88 final String? lldbInitFileLaunchPath;
89 final String? lldbInitFileTestPath;
90 try {
91 // Check that both the LaunchAction and TestAction have the customLLDBInitFile set to flutter_lldbinit.
92 final document = XmlDocument.parse(schemeInfo.schemeContent);
93
94 lldbInitFileLaunchPath = _parseLLDBInitFileFromScheme(
95 action: _launchActionIdentifier,
96 document: document,
97 schemeFile: schemeInfo.schemeFile,
98 );
99 lldbInitFileTestPath = _parseLLDBInitFileFromScheme(
100 action: _testActionIdentifier,
101 document: document,
102 schemeFile: schemeInfo.schemeFile,
103 );
104 final bool launchActionMigrated =
105 lldbInitFileLaunchPath != null && lldbInitFileLaunchPath.contains(_initPath);
106 final bool testActionMigrated =
107 lldbInitFileTestPath != null && lldbInitFileTestPath.contains(_initPath);
108
109 if (launchActionMigrated && testActionMigrated) {
110 return true;
111 } else if (launchActionMigrated && !testActionMigrated) {
112 // If LaunchAction has it set, but TestAction doesn't, give an error
113 // with instructions to add it to the TestAction.
114 throw _missingActionException('Test', schemeInfo.schemeName);
115 } else if (testActionMigrated && !launchActionMigrated) {
116 // If TestAction has it set, but LaunchAction doesn't, give an error
117 // with instructions to add it to the LaunchAction.
118 throw _missingActionException('Launch', schemeInfo.schemeName);
119 }
120 } on XmlException catch (exception) {
121 throw Exception(
122 'Failed to parse ${schemeInfo.schemeFile.basename}: Invalid xml: ${schemeInfo.schemeContent}\n$exception',
123 );
124 }
125
126 // If the scheme is using a LLDB Init File that is not flutter_lldbinit,
127 // attempt to read the file and check if it's importing flutter_lldbinit.
128 // If the file name contains a variable, attempt to substitute the variable
129 // using the build settings. If it fails to find the file or fails to
130 // detect it's using flutter_lldbinit, print a warning to either remove
131 // their LLDB Init file or append flutter_lldbinit to their existing one.
132 if (schemeInfo.schemeContent.contains('customLLDBInitFile')) {
133 try {
134 Map<String, String>? buildSettings;
135 if ((lldbInitFileLaunchPath != null && lldbInitFileLaunchPath.contains(r'$')) ||
136 (lldbInitFileTestPath != null && lldbInitFileTestPath.contains(r'$'))) {
137 buildSettings =
138 await _xcodeProject.buildSettingsForBuildInfo(
139 _buildInfo,
140 environmentType: _environmentType,
141 deviceId: _deviceID,
142 ) ??
143 <String, String>{};
144 }
145
146 final File? lldbInitFileLaunchFile = _resolveLLDBInitFile(
147 lldbInitFileLaunchPath,
148 buildSettings,
149 );
150 final File? lldbInitFileTestFile = _resolveLLDBInitFile(
151 lldbInitFileTestPath,
152 buildSettings,
153 );
154
155 if (lldbInitFileLaunchFile != null &&
156 lldbInitFileLaunchFile.existsSync() &&
157 lldbInitFileLaunchFile.readAsStringSync().contains(
158 _xcodeProject.lldbInitFile.basename,
159 ) &&
160 lldbInitFileTestFile != null &&
161 lldbInitFileTestFile.existsSync() &&
162 lldbInitFileTestFile.readAsStringSync().contains(_xcodeProject.lldbInitFile.basename)) {
163 return true;
164 }
165 } on XmlException catch (exception) {
166 throw Exception(
167 'Failed to parse ${schemeInfo.schemeFile.basename}: Invalid xml: ${schemeInfo.schemeContent}\n$exception',
168 );
169 }
170
171 throw Exception(
172 'Running Flutter in debug mode on new iOS versions requires a LLDB '
173 'Init File, but the scheme already has one set. To ensure debug '
174 'mode works, please complete one of the following:\n'
175 ' * Open Xcode > Product > Scheme > Edit Scheme and remove LLDB Init '
176 'File for both the Run and Test actions.\n'
177 ' * Append the following to your custom LLDB Init File:\n\n'
178 ' command source ${_xcodeProject.lldbInitFile.absolute.path}\n',
179 );
180 }
181 return false;
182 }
183
184 /// Add customLLDBInitFile and set to [_initPath] for both LaunchAction and TestAction.
185 void _migrateScheme(SchemeInfo schemeInfo) {
186 final File schemeFile = schemeInfo.schemeFile;
187 final String schemeContent = schemeInfo.schemeContent;
188
189 final String newScheme = schemeContent.replaceAll(
190 'selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"',
191 '''
192selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
193 customLLDBInitFile = "$_initPath"''',
194 );
195 try {
196 final document = XmlDocument.parse(newScheme);
197 _validateSchemeAction(
198 action: _launchActionIdentifier,
199 document: document,
200 schemeFile: schemeFile,
201 );
202 _validateSchemeAction(
203 action: _testActionIdentifier,
204 document: document,
205 schemeFile: schemeFile,
206 );
207 } on XmlException catch (exception) {
208 throw Exception(
209 'Failed to parse ${schemeFile.basename}: Invalid xml: $newScheme\n$exception',
210 );
211 }
212 schemeFile.writeAsStringSync(newScheme);
213 }
214
215 /// Parse the customLLDBInitFile from the XML for the [action] and validate
216 /// it contains [_initPath].
217 void _validateSchemeAction({
218 required String action,
219 required XmlDocument document,
220 required File schemeFile,
221 }) {
222 final String? lldbInitFile = _parseLLDBInitFileFromScheme(
223 action: action,
224 document: document,
225 schemeFile: schemeFile,
226 );
227 if (lldbInitFile == null || !lldbInitFile.contains(_initPath)) {
228 throw Exception(
229 'Failed to find correct customLLDBInitFile in $action for the Scheme in ${schemeFile.path}.',
230 );
231 }
232 }
233
234 /// Parse the customLLDBInitFile from the XML for the [action].
235 String? _parseLLDBInitFileFromScheme({
236 required String action,
237 required XmlDocument document,
238 required File schemeFile,
239 }) {
240 final Iterable<XmlNode> nodes = document.xpath('/Scheme/$action');
241 if (nodes.isEmpty) {
242 throw Exception('Failed to find $action for the Scheme in ${schemeFile.path}.');
243 }
244 final XmlNode actionNode = nodes.first;
245 final XmlAttribute? lldbInitFile = actionNode.attributes
246 .where((XmlAttribute attribute) => attribute.localName == 'customLLDBInitFile')
247 .firstOrNull;
248 return lldbInitFile?.value;
249 }
250
251 /// Replace any Xcode variables in [lldbInitFilePath] from [buildSettings].
252 File? _resolveLLDBInitFile(String? lldbInitFilePath, Map<String, String>? buildSettings) {
253 if (lldbInitFilePath == null) {
254 return null;
255 }
256 if (lldbInitFilePath.contains(r'$') && buildSettings != null) {
257 // If the path to the LLDB Init File contains a $, it may contain a
258 // variable from build settings.
259 final String resolvedInitFilePath = substituteXcodeVariables(lldbInitFilePath, buildSettings);
260 return _fileSystem.file(resolvedInitFilePath);
261 }
262 return _fileSystem.file(lldbInitFilePath);
263 }
264
265 Exception _missingActionException(String missingAction, String schemeName) {
266 return Exception(
267 'Running Flutter in debug mode on new iOS versions requires a LLDB '
268 'Init File, but the $missingAction action in the $schemeName scheme '
269 'does not have it set. To ensure debug mode works, please complete '
270 'the following:\n'
271 ' * Open Xcode > Product > Scheme > Edit Scheme and for the '
272 '$missingAction action, set LLDB Init File to:\n\n'
273 ' $_initPath\n',
274 );
275 }
276}
277
278class SchemeInfo {
279 SchemeInfo({required this.schemeName, required this.schemeFile, required this.schemeContent});
280
281 final String schemeName;
282 final File schemeFile;
283 final String schemeContent;
284}
285