// 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:uuid/uuid.dart'; import 'package:yaml/yaml.dart'; import '../android/gradle_utils.dart' as gradle; import '../base/common.dart'; import '../base/file_system.dart'; import '../base/utils.dart'; import '../cache.dart'; import '../convert.dart'; import '../flutter_project_metadata.dart'; import '../globals.dart' as globals; import '../project.dart'; import '../runner/flutter_command.dart'; import '../template.dart'; const _kAvailablePlatforms = ['ios', 'android', 'windows', 'linux', 'macos', 'web']; /// A list of all possible create platforms, even those that may not be enabled /// with the current config. const kAllCreatePlatforms = [ 'ios', 'android', 'windows', 'linux', 'macos', 'web', 'darwin', ]; const _kDefaultPlatformArgumentHelp = '(required) The platforms supported by this project. ' 'Platform folders (e.g. android/) will be generated in the target project. ' 'Adding desktop platforms requires the corresponding desktop config setting to be enabled.'; /// Common behavior for `flutter create` and `flutter widget-preview start` commands. mixin CreateBase on FlutterCommand { /// Pattern for a Windows file system drive (e.g. "D:"). /// /// `dart:io` does not recognize strings matching this pattern as absolute /// paths, as they have no top level back-slash; however, users often specify /// this @visibleForTesting @protected static final kWindowsDrivePattern = RegExp(r'^[a-zA-Z]:$'); /// The output directory of the command. @protected @visibleForTesting Directory get projectDir { final String argProjectDir = argResults!.rest.first; if (globals.platform.isWindows && kWindowsDrivePattern.hasMatch(argProjectDir)) { throwToolExit( 'You attempted to create a flutter project at the path "$argProjectDir", which is the name of a drive. This ' 'is usually a mistake--you probably want to specify a containing directory, like "$argProjectDir\\app_name". ' 'If you really want it at the drive root, re-run the command with the root directory after the drive, like ' '"$argProjectDir\\".', ); } return globals.fs.directory(argResults!.rest.first); } /// The normalized absolute path of [projectDir]. @protected String get projectDirPath { return globals.fs.path.normalize(projectDir.absolute.path); } @protected bool get shouldCallPubGet { return boolArg('pub'); } @protected bool get offline { return boolArg('offline'); } /// Adds `--pub` and `--offline` options. @protected void addPubOptions() { argParser ..addFlag( 'pub', defaultsTo: true, help: 'Whether to run "flutter pub get" after the project has been created.', ) ..addFlag( 'offline', help: 'When "flutter pub get" is run by the create command, this indicates ' 'whether to run it in offline mode or not. In offline mode, it will need to ' 'have all dependencies already available in the pub cache to succeed.', ); } /// Adds a `--platforms` argument. /// /// The help message of the argument is replaced with `customHelp` if `customHelp` is not null. @protected void addPlatformsOptions({String? customHelp, required Map allowedHelp}) { argParser.addMultiOption( 'platforms', help: customHelp ?? _kDefaultPlatformArgumentHelp, aliases: ['platform'], defaultsTo: [..._kAvailablePlatforms], allowed: [...kAllCreatePlatforms], allowedHelp: allowedHelp, ); } /// Throw with exit code 2 if the output directory is invalid. @protected void validateOutputDirectoryArg() { final List? rest = argResults?.rest; if (rest == null || rest.isEmpty) { throwToolExit('No option specified for the output directory.\n$usage', exitCode: 2); } if (rest.length > 1) { var message = 'Multiple output directories specified.'; for (final String arg in rest) { if (arg.startsWith('-')) { message += '\nTry moving $arg to be immediately following $name'; break; } } throwToolExit(message, exitCode: 2); } } /// Gets the flutter root directory. @protected String get flutterRoot => Cache.flutterRoot!; /// Determines the project type in an existing flutter project. /// /// If it has a .metadata file with the project_type in it, use that. /// If it has an android dir and an android/app dir, it's a legacy app /// If it has an ios dir and an ios/Flutter dir, it's a legacy app /// Otherwise, we don't presume to know what type of project it could be, since /// many of the files could be missing, and we can't really tell definitively. /// /// Throws assertion if [projectDir] does not exist or empty. /// Returns null if no project type can be determined. @protected FlutterTemplateType? determineTemplateType() { assert(projectDir.existsSync() && projectDir.listSync().isNotEmpty); final File metadataFile = globals.fs.file( globals.fs.path.join(projectDir.absolute.path, '.metadata'), ); final projectMetadata = FlutterProjectMetadata(metadataFile, globals.logger); final FlutterTemplateType? projectType = projectMetadata.projectType; if (projectType != null) { return projectType; } bool exists(List path) { return globals.fs .directory(globals.fs.path.joinAll([projectDir.absolute.path, ...path])) .existsSync(); } // There either wasn't any metadata, or it didn't contain the project type, // so try and figure out what type of project it is from the existing // directory structure. if (exists(['android', 'app']) || exists(['ios', 'Runner']) || exists(['ios', 'Flutter'])) { return FlutterTemplateType.app; } // Since we can't really be definitive on nearly-empty directories, err on // the side of prudence and just say we don't know. return null; } /// Determines the organization. /// /// If `--org` is specified in the command, returns that directly. /// If `--org` is not specified, returns the organization from the existing project. @protected Future getOrganization() async { String? organization = stringArg('org'); if (!argResults!.wasParsed('org')) { final FlutterProject project = FlutterProject.fromDirectory(projectDir); final Set existingOrganizations = await project.organizationNames; if (existingOrganizations.length == 1) { organization = existingOrganizations.first; } else if (existingOrganizations.length > 1) { throwToolExit( 'Ambiguous organization in existing files: $existingOrganizations. ' 'The --org command line argument must be specified to recreate project.', ); } } if (organization == null) { throwToolExit('The --org command line argument must be specified to create a project.'); } return organization; } /// Throws with exit 2 if the project directory is illegal. @protected void validateProjectDir({bool overwrite = false}) { if (globals.fs.path.isWithin(flutterRoot, projectDirPath)) { // Make exception for dev and examples to facilitate example project development. final String examplesDirectory = globals.fs.path.join(flutterRoot, 'examples'); final String devDirectory = globals.fs.path.join(flutterRoot, 'dev'); if (!globals.fs.path.isWithin(examplesDirectory, projectDirPath) && !globals.fs.path.isWithin(devDirectory, projectDirPath)) { throwToolExit( 'Cannot create a project within the Flutter SDK. ' "Target directory '$projectDirPath' is within the Flutter SDK at '$flutterRoot'.", exitCode: 2, ); } } // If the destination directory is actually a file, then we refuse to // overwrite, on the theory that the user probably didn't expect it to exist. if (globals.fs.isFileSync(projectDirPath)) { final message = "Invalid project name: '$projectDirPath' - refers to an existing file."; throwToolExit( overwrite ? '$message Refusing to overwrite a file with a directory.' : message, exitCode: 2, ); } if (overwrite) { return; } final FileSystemEntityType type = globals.fs.typeSync(projectDirPath); // ignore: exhaustive_cases, https://github.com/dart-lang/linter/issues/3017 switch (type) { case FileSystemEntityType.file: // Do not overwrite files. throwToolExit("Invalid project name: '$projectDirPath' - file exists.", exitCode: 2); case FileSystemEntityType.link: // Do not overwrite links. throwToolExit("Invalid project name: '$projectDirPath' - refers to a link.", exitCode: 2); case FileSystemEntityType.directory: case FileSystemEntityType.notFound: break; } } /// Gets the project name. /// /// If the `--project-name` is not specified explicitly, /// the `name` field from the pubspec.yaml file is used. /// /// If the pubspec.yaml file does not exist, /// the current directory path name is used. @protected String get projectName { String? projectName = stringArg('project-name'); if (projectName == null) { final File pubspec = globals.fs.directory(projectDirPath).childFile('pubspec.yaml'); if (pubspec.existsSync()) { final String pubspecContents = pubspec.readAsStringSync(); try { final Object? pubspecYaml = loadYaml(pubspecContents); if (pubspecYaml is YamlMap) { final Object? pubspecName = pubspecYaml['name']; if (pubspecName is String) { projectName = pubspecName; } } } on YamlException { // If the pubspec is malformed, fallback to using the directory name. } } final String projectDirName = globals.fs.path.basename(projectDirPath); projectName ??= projectDirName; } if (!boolArg('skip-name-checks')) { final String? error = _validateProjectName(projectName); if (error != null) { throwToolExit(error); } } return projectName; } /// Creates a template to use for [renderTemplate]. @protected Map createTemplateContext({ required String organization, required String projectName, required String titleCaseProjectName, String? projectDescription, String? androidLanguage, String? iosDevelopmentTeam, required String flutterRoot, required String dartSdkVersionBounds, String? agpVersion, String? kotlinVersion, String? gradleVersion, bool withPlatformChannelPluginHook = false, bool withSwiftPackageManager = false, bool withFfiPluginHook = false, bool withFfiPackage = false, bool withEmptyMain = false, bool ios = false, bool android = false, bool web = false, bool linux = false, bool macos = false, bool windows = false, bool darwin = false, bool implementationTests = false, }) { final String pluginDartClass = _createPluginClassName(projectName); final pluginClass = pluginDartClass.endsWith('Plugin') ? pluginDartClass : '${pluginDartClass}Plugin'; final String pluginClassSnakeCase = snakeCase(pluginClass); final String pluginClassCapitalSnakeCase = pluginClassSnakeCase.toUpperCase(); final String pluginClassLowerCamelCase = pluginClass[0].toLowerCase() + pluginClass.substring(1); final String appleIdentifier = createUTIIdentifier(organization, projectName); final String androidIdentifier = createAndroidIdentifier(organization, projectName); final String windowsIdentifier = createWindowsIdentifier(organization, projectName); // Linux uses the same scheme as the Android identifier. // https://developer.gnome.org/gio/stable/GApplication.html#g-application-id-is-valid final linuxIdentifier = androidIdentifier; return { 'organization': organization, 'projectName': projectName, 'titleCaseProjectName': titleCaseProjectName, 'androidIdentifier': androidIdentifier, 'iosIdentifier': appleIdentifier, 'macosIdentifier': appleIdentifier, 'darwinIdentifier': appleIdentifier, 'linuxIdentifier': linuxIdentifier, 'windowsIdentifier': windowsIdentifier, 'description': projectDescription, 'dartSdk': '$flutterRoot/bin/cache/dart-sdk', 'androidMinApiLevel': gradle.minSdkVersion, 'androidSdkVersion': gradle.minSdkVersion, 'pluginClass': pluginClass, 'pluginClassSnakeCase': pluginClassSnakeCase, 'pluginClassLowerCamelCase': pluginClassLowerCamelCase, 'pluginClassCapitalSnakeCase': pluginClassCapitalSnakeCase, 'pluginDartClass': pluginDartClass, 'pluginProjectUUID': const Uuid().v4().toUpperCase(), 'withFfi': withFfiPluginHook || withFfiPackage, 'withFfiPackage': withFfiPackage, 'withFfiPluginHook': withFfiPluginHook, 'withPlatformChannelPluginHook': withPlatformChannelPluginHook, 'withSwiftPackageManager': withSwiftPackageManager, 'withPluginHook': withFfiPluginHook || withFfiPackage || withPlatformChannelPluginHook, 'withEmptyMain': withEmptyMain, 'androidLanguage': androidLanguage, 'hasIosDevelopmentTeam': iosDevelopmentTeam != null && iosDevelopmentTeam.isNotEmpty, 'iosDevelopmentTeam': iosDevelopmentTeam ?? '', 'flutterRevision': escapeYamlString(globals.flutterVersion.frameworkRevision), 'flutterChannel': escapeYamlString(globals.flutterVersion.getBranchName()), // may contain PII 'ios': ios, 'android': android, 'web': web, 'linux': linux, 'macos': macos, 'darwin': darwin, 'sharedDarwinSource': darwin, 'windows': windows, 'year': DateTime.now().year, 'dartSdkVersionBounds': dartSdkVersionBounds, 'implementationTests': implementationTests, 'agpVersion': agpVersion, 'agpVersionForModule': gradle.templateAndroidGradlePluginVersionForModule, 'kotlinVersion': kotlinVersion, 'gradleVersion': gradleVersion, 'compileSdkVersion': gradle.compileSdkVersion, 'minSdkVersion': gradle.minSdkVersion, 'ndkVersion': gradle.ndkVersion, 'targetSdkVersion': gradle.targetSdkVersion, }; } /// Renders the template, generate files into `directory`. /// /// `templateName` should match one of directory names under flutter_tools/template/. /// If `overwrite` is true, overwrites existing files, `overwrite` defaults to `false`. @protected Future renderTemplate( String templateName, Directory directory, Map context, { bool overwrite = false, bool printStatusWhenWriting = true, }) async { final Template template = await Template.fromName( templateName, fileSystem: globals.fs, logger: globals.logger, templateRenderer: globals.templateRenderer, templateManifest: _templateManifest, ); return template.render( directory, context, overwriteExisting: overwrite, printStatusWhenWriting: printStatusWhenWriting, ); } /// Merges named templates into a single template, output to `directory`. /// /// `names` should match directory names under flutter_tools/template/. /// /// If `overwrite` is true, overwrites existing files, `overwrite` defaults to `false`. @protected Future renderMerged( List names, Directory directory, Map context, { bool overwrite = false, bool printStatusWhenWriting = true, }) async { final Template template = await Template.merged( names, directory, fileSystem: globals.fs, logger: globals.logger, templateRenderer: globals.templateRenderer, templateManifest: _templateManifest, ); return template.render( directory, context, overwriteExisting: overwrite, printStatusWhenWriting: printStatusWhenWriting, ); } /// Generate application project in the `directory` using `templateContext`. /// /// If `overwrite` is true, overwrites existing files, `overwrite` defaults to `false`. @protected Future generateApp( List templateNames, Directory directory, Map templateContext, { bool overwrite = false, bool pluginExampleApp = false, bool printStatusWhenWriting = true, bool generateMetadata = true, FlutterTemplateType? projectType, }) async { var generatedCount = 0; generatedCount += await renderMerged( [...templateNames], directory, templateContext, overwrite: overwrite, printStatusWhenWriting: printStatusWhenWriting, ); final FlutterProject project = FlutterProject.fromDirectory(directory); if (templateContext['android'] == true) { generatedCount += _injectGradleWrapper(project); } final bool androidPlatform = templateContext['android'] as bool? ?? false; final bool iosPlatform = templateContext['ios'] as bool? ?? false; final bool linuxPlatform = templateContext['linux'] as bool? ?? false; final bool macOSPlatform = templateContext['macos'] as bool? ?? false; final bool windowsPlatform = templateContext['windows'] as bool? ?? false; final bool webPlatform = templateContext['web'] as bool? ?? false; final bool darwinPlatform = templateContext['darwin'] as bool? ?? false; final platformsForMigrateConfig = [SupportedPlatform.root]; if (androidPlatform) { gradle.updateLocalProperties(project: project, requireAndroidSdk: false); platformsForMigrateConfig.add(SupportedPlatform.android); } if (iosPlatform) { platformsForMigrateConfig.add(SupportedPlatform.ios); } if (linuxPlatform) { platformsForMigrateConfig.add(SupportedPlatform.linux); } if (macOSPlatform) { platformsForMigrateConfig.add(SupportedPlatform.macos); } if (darwinPlatform) { if (!platformsForMigrateConfig.contains(SupportedPlatform.ios)) { platformsForMigrateConfig.add(SupportedPlatform.ios); } if (!platformsForMigrateConfig.contains(SupportedPlatform.macos)) { platformsForMigrateConfig.add(SupportedPlatform.macos); } } if (webPlatform) { platformsForMigrateConfig.add(SupportedPlatform.web); } if (windowsPlatform) { platformsForMigrateConfig.add(SupportedPlatform.windows); } if (templateContext['fuchsia'] == true) { platformsForMigrateConfig.add(SupportedPlatform.fuchsia); } if (generateMetadata) { final File metadataFile = globals.fs.file( globals.fs.path.join(projectDir.absolute.path, '.metadata'), ); final metadata = FlutterProjectMetadata.explicit( file: metadataFile, versionRevision: globals.flutterVersion.frameworkRevision, versionChannel: globals.flutterVersion.getBranchName(), // may contain PII projectType: projectType, migrateConfig: MigrateConfig(), logger: globals.logger, ); metadata.populate( platforms: platformsForMigrateConfig, projectDirectory: directory, update: false, currentRevision: stringArg('initial-create-revision') ?? globals.flutterVersion.frameworkRevision, createRevision: globals.flutterVersion.frameworkRevision, logger: globals.logger, ); metadata.writeFile(); } return generatedCount; } /// Creates an android identifier. /// /// Android application ID is specified in: https://developer.android.com/studio/build/application-id /// All characters must be alphanumeric or an underscore [a-zA-Z0-9_]. static String createAndroidIdentifier(String organization, String name) { var tmpIdentifier = '$organization.$name'; final disallowed = RegExp(r'[^\w\.]'); tmpIdentifier = tmpIdentifier.replaceAll(disallowed, ''); // It must have at least two segments (one or more dots). final List segments = tmpIdentifier .split('.') .where((String segment) => segment.isNotEmpty) .toList(); while (segments.length < 2) { segments.add('untitled'); } // Each segment must start with a letter. final segmentPatternRegex = RegExp(r'^[a-zA-Z][\w]*$'); final List prefixedSegments = segments.map((String segment) { if (!segmentPatternRegex.hasMatch(segment)) { return 'u$segment'; } return segment; }).toList(); return prefixedSegments.join('.'); } /// Creates a Windows package name. /// /// Package names must be a globally unique, commonly a GUID. static String createWindowsIdentifier(String organization, String name) { return const Uuid().v4().toUpperCase(); } String _createPluginClassName(String name) { final String camelizedName = camelCase(name); return camelizedName[0].toUpperCase() + camelizedName.substring(1); } /// Create a UTI (https://en.wikipedia.org/wiki/Uniform_Type_Identifier) from a base name static String createUTIIdentifier(String organization, String name) { name = camelCase(name); var tmpIdentifier = '$organization.$name'; final disallowed = RegExp(r'[^a-zA-Z0-9\-\.\u0080-\uffff]+'); tmpIdentifier = tmpIdentifier.replaceAll(disallowed, ''); // It must have at least two segments (one or more dots). final List segments = tmpIdentifier .split('.') .where((String segment) => segment.isNotEmpty) .toList(); while (segments.length < 2) { segments.add('untitled'); } return segments.join('.'); } late final Set _templateManifest = _computeTemplateManifest(); Set _computeTemplateManifest() { final String flutterToolsAbsolutePath = globals.fs.path.join( Cache.flutterRoot!, 'packages', 'flutter_tools', ); final String manifestPath = globals.fs.path.join( flutterToolsAbsolutePath, 'templates', 'template_manifest.json', ); final String manifestFileContents; try { manifestFileContents = globals.fs.file(manifestPath).readAsStringSync(); } on FileSystemException catch (e) { throwToolExit( 'Unable to read the template manifest at path "$manifestPath".\n' 'Make sure that your user account has sufficient permissions to read this file.\n' 'Exception details: $e', ); } final manifest = json.decode(manifestFileContents) as Map; return Set.from( (manifest['files']! as List).cast().map( (String path) => Uri.file(globals.fs.path.join(flutterToolsAbsolutePath, path)), ), ); } int _injectGradleWrapper(FlutterProject project) { var filesCreated = 0; copyDirectory( globals.cache.getArtifactDirectory('gradle_wrapper'), project.android.hostAppGradleRoot, onFileCopied: (File sourceFile, File destinationFile) { filesCreated++; final String modes = sourceFile.statSync().modeString(); if (modes.contains('x')) { globals.os.makeExecutable(destinationFile); } }, ); return filesCreated; } } // A valid Dart identifier that can be used for a package, i.e. no // capital letters. // https://dart.dev/language#important-concepts final _identifierRegExp = RegExp('[a-z_][a-z0-9_]*'); // non-contextual dart keywords. // https://dart.dev/language/keywords const _keywords = { 'abstract', 'as', 'assert', 'async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'covariant', 'default', 'deferred', 'do', 'dynamic', 'else', 'enum', 'export', 'extends', 'extension', 'external', 'factory', 'false', 'final', 'finally', 'for', 'function', 'get', 'hide', 'if', 'implements', 'import', 'in', 'inout', 'interface', 'is', 'late', 'library', 'mixin', 'native', 'new', 'null', 'of', 'on', 'operator', 'out', 'part', 'patch', 'required', 'rethrow', 'return', 'set', 'show', 'source', 'static', 'super', 'switch', 'sync', 'this', 'throw', 'true', 'try', 'typedef', 'var', 'void', 'while', 'with', 'yield', }; const _packageDependencies = {'collection', 'flutter', 'flutter_test', 'meta'}; /// Whether [name] is a valid Pub package. @visibleForTesting bool isValidPackageName(String name) { final Match? match = _identifierRegExp.matchAsPrefix(name); return match != null && match.end == name.length && !_keywords.contains(name); } /// Returns a potential valid name from the given [name]. /// /// If a valid name cannot be found, returns `null`. @visibleForTesting String? potentialValidPackageName(String name) { String newName = name.toLowerCase(); if (newName.startsWith(RegExp(r'[0-9]'))) { newName = '_$newName'; } newName = newName.replaceAll('-', '_'); if (isValidPackageName(newName)) { return newName; } else { return null; } } // Return null if the project name is legal. Return a validation message if // we should disallow the project name. String? _validateProjectName(String projectName) { if (!isValidPackageName(projectName)) { final String? potentialValidName = potentialValidPackageName(projectName); return '"$projectName" is not a valid Dart package name.' '${potentialValidName != null ? ' Try "$potentialValidName" instead.' : ''}\n' '\n' 'The name should consist of lowercase words separated by underscores, "like_this". ' 'Use only basic Latin letters and Arabic digits: [a-z0-9_], and ' 'ensure the name is a valid Dart identifier ' '(i.e. it does not start with a digit and is not a reserved word).\n' '\n' 'See https://dart.dev/tools/pub/pubspec#name for more information.'; } if (_packageDependencies.contains(projectName)) { return "Invalid project name: '$projectName' - this will conflict with Flutter " 'package dependencies.'; } return null; }