flutter_project_metadata.dart 12.7 KB
Newer Older
1 2 3 4 5 6 7 8 9
// 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:yaml/yaml.dart';

import 'base/file_system.dart';
import 'base/logger.dart';
import 'base/utils.dart';
10 11
import 'project.dart';
import 'version.dart';
12

13
enum FlutterProjectType implements CliEnum {
14 15 16 17
  /// This is the default project with the user-managed host code.
  /// It is different than the "module" template in that it exposes and doesn't
  /// manage the platform code.
  app,
18

19 20
  /// A List/Detail app template that follows community best practices.
  skeleton,
21

22 23 24
  /// The is a project that has managed platform host code. It is an application with
  /// ephemeral .ios and .android directories that can be updated automatically.
  module,
25

26 27 28
  /// This is a Flutter Dart package project. It doesn't have any native
  /// components, only Dart.
  package,
29

30 31
  /// This is a native plugin project.
  plugin,
32

Daco Harkes's avatar
Daco Harkes committed
33
  /// This is an FFI native plugin project.
34
  pluginFfi;
35

36 37
  @override
  String get cliName => snakeCase(name);
38

39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
  @override
  String get helpText => switch (this) {
        FlutterProjectType.app => '(default) Generate a Flutter application.',
        FlutterProjectType.skeleton =>
          'Generate a List View / Detail View Flutter application that follows community best practices.',
        FlutterProjectType.package =>
          'Generate a shareable Flutter project containing modular Dart code.',
        FlutterProjectType.plugin =>
          'Generate a shareable Flutter project containing an API '
          'in Dart code with a platform-specific implementation through method channels for Android, iOS, '
          'Linux, macOS, Windows, web, or any combination of these.',
        FlutterProjectType.pluginFfi =>
          'Generate a shareable Flutter project containing an API '
          'in Dart code with a platform-specific implementation through dart:ffi for Android, iOS, '
          'Linux, macOS, Windows, or any combination of these.',
        FlutterProjectType.module =>
          'Generate a project to add a Flutter module to an existing Android or iOS application.',
      };

  static FlutterProjectType? fromCliName(String value) {
    for (final FlutterProjectType type in FlutterProjectType.values) {
      if (value == type.cliName) {
        return type;
      }
63
    }
64
    return null;
65 66 67
  }
}

68
  /// Verifies the expected yaml keys are present in the file.
69
  bool _validateMetadataMap(YamlMap map, Map<String, Type> validations, Logger logger) {
70 71 72 73 74 75 76
    bool isValid = true;
    for (final MapEntry<String, Object> entry in validations.entries) {
      if (!map.keys.contains(entry.key)) {
        isValid = false;
        logger.printTrace('The key `${entry.key}` was not found');
        break;
      }
77 78
      final Object? metadataValue = map[entry.key];
      if (metadataValue.runtimeType != entry.value) {
79
        isValid = false;
80
        logger.printTrace('The value of key `${entry.key}` in .metadata was expected to be ${entry.value} but was ${metadataValue.runtimeType}');
81 82 83 84 85 86
        break;
      }
    }
    return isValid;
  }

87 88
/// A wrapper around the `.metadata` file.
class FlutterProjectMetadata {
89
  /// Creates a MigrateConfig by parsing an existing .migrate_config yaml file.
90
  FlutterProjectMetadata(this.file, Logger logger) : _logger = logger,
91
                                                     migrateConfig = MigrateConfig() {
92 93
    if (!file.existsSync()) {
      _logger.printTrace('No .metadata file found at ${file.path}.');
94 95 96 97 98
      // Create a default empty metadata.
      return;
    }
    Object? yamlRoot;
    try {
99
      yamlRoot = loadYaml(file.readAsStringSync());
100 101 102
    } on YamlException {
      // Handled in _validate below.
    }
103
    if (yamlRoot is! YamlMap) {
104
      _logger.printTrace('.metadata file at ${file.path} was empty or malformed.');
105 106 107
      return;
    }
    if (_validateMetadataMap(yamlRoot, <String, Type>{'version': YamlMap}, _logger)) {
108 109
      final Object? versionYamlMap = yamlRoot['version'];
      if (versionYamlMap is YamlMap && _validateMetadataMap(versionYamlMap, <String, Type>{
110 111 112 113 114 115 116 117
            'revision': String,
            'channel': String,
          }, _logger)) {
        _versionRevision = versionYamlMap['revision'] as String?;
        _versionChannel = versionYamlMap['channel'] as String?;
      }
    }
    if (_validateMetadataMap(yamlRoot, <String, Type>{'project_type': String}, _logger)) {
118
      _projectType = FlutterProjectType.fromCliName(yamlRoot['project_type'] as String);
119
    }
120 121 122
    final Object? migrationYaml = yamlRoot['migration'];
    if (migrationYaml is YamlMap) {
      migrateConfig.parseYaml(migrationYaml, _logger);
123 124 125
    }
  }

126
  /// Creates a FlutterProjectMetadata by explicitly providing all values.
127
  FlutterProjectMetadata.explicit({
128
    required this.file,
129 130 131 132 133 134 135 136
    required String? versionRevision,
    required String? versionChannel,
    required FlutterProjectType? projectType,
    required this.migrateConfig,
    required Logger logger,
  }) : _logger = logger,
       _versionChannel = versionChannel,
       _versionRevision = versionRevision,
137
       _projectType = projectType;
138 139 140 141 142 143 144 145 146 147 148 149 150 151 152

  /// The name of the config file.
  static const String kFileName = '.metadata';

  String? _versionRevision;
  String? get versionRevision => _versionRevision;

  String? _versionChannel;
  String? get versionChannel => _versionChannel;

  FlutterProjectType? _projectType;
  FlutterProjectType? get projectType => _projectType;

  /// Metadata and configuration for the migrate command.
  MigrateConfig migrateConfig;
153 154 155

  final Logger _logger;

156
  final File file;
157 158 159 160 161 162

  /// Writes the .migrate_config file in the provided project directory's platform subdirectory.
  ///
  /// We write the file manually instead of with a template because this
  /// needs to be able to write the .migrate_config file into legacy apps.
  void writeFile({File? outputFile}) {
163
    outputFile = outputFile ?? file;
164 165
    outputFile
      ..createSync(recursive: true)
166 167 168 169 170 171
      ..writeAsStringSync(toString(), flush: true);
  }

  @override
  String toString() {
    return '''
172 173 174 175 176 177 178 179 180
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled.

version:
  revision: $_versionRevision
  channel: $_versionChannel

181
project_type: ${projectType == null ? '' : projectType!.cliName}
182
${migrateConfig.getOutputFileString()}''';
183 184 185 186
  }

  void populate({
    List<SupportedPlatform>? platforms,
187
    required Directory projectDirectory,
188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203
    String? currentRevision,
    String? createRevision,
    bool create = true,
    bool update = true,
    required Logger logger,
  }) {
    migrateConfig.populate(
      platforms: platforms,
      projectDirectory: projectDirectory,
      currentRevision: currentRevision,
      createRevision: createRevision,
      create: create,
      update: update,
      logger: logger,
    );
  }
204

205 206 207 208 209
  /// Finds the fallback revision to use when no base revision is found in the migrate config.
  String getFallbackBaseRevision(Logger logger, FlutterVersion flutterVersion) {
    // Use the .metadata file if it exists.
    if (versionRevision != null) {
      return versionRevision!;
210
    }
211
    return flutterVersion.frameworkRevision;
212
  }
213 214 215 216 217 218 219 220 221 222 223 224
}

/// Represents the migrate command metadata section of a .metadata file.
///
/// This file tracks the flutter sdk git hashes of the last successful migration ('base') and
/// the version the project was created with.
///
/// Each platform tracks a different set of revisions because flutter create can be
/// used to add support for new platforms, so the base and create revision may not always be the same.
class MigrateConfig {
  MigrateConfig({
    Map<SupportedPlatform, MigratePlatformConfig>? platformConfigs,
225
    this.unmanagedFiles = kDefaultUnmanagedFiles
226 227
  }) : platformConfigs = platformConfigs ?? <SupportedPlatform, MigratePlatformConfig>{};

Lioness100's avatar
Lioness100 committed
228
  /// A mapping of the files that are unmanaged by default for each platform.
229
  static const List<String> kDefaultUnmanagedFiles = <String>[
230 231 232 233 234 235
    'lib/main.dart',
    'ios/Runner.xcodeproj/project.pbxproj',
  ];

  /// The metadata for each platform supported by the project.
  final Map<SupportedPlatform, MigratePlatformConfig> platformConfigs;
236

237 238 239 240 241
  /// A list of paths relative to this file the migrate tool should ignore.
  ///
  /// These files are typically user-owned files that should not be changed.
  List<String> unmanagedFiles;

242
  bool get isEmpty => platformConfigs.isEmpty && (unmanagedFiles.isEmpty || unmanagedFiles == kDefaultUnmanagedFiles);
243 244 245 246 247

  /// Parses the project for all supported platforms and populates the [MigrateConfig]
  /// to reflect the project.
  void populate({
    List<SupportedPlatform>? platforms,
248
    required Directory projectDirectory,
249 250 251 252 253 254
    String? currentRevision,
    String? createRevision,
    bool create = true,
    bool update = true,
    required Logger logger,
  }) {
255
    final FlutterProject flutterProject = FlutterProject.fromDirectory(projectDirectory);
256 257 258 259 260 261 262
    platforms ??= flutterProject.getSupportedPlatforms(includeRoot: true);

    for (final SupportedPlatform platform in platforms) {
      if (platformConfigs.containsKey(platform)) {
        if (update) {
          platformConfigs[platform]!.baseRevision = currentRevision;
        }
263
      } else {
264
        if (create) {
265
          platformConfigs[platform] = MigratePlatformConfig(platform: platform, createRevision: createRevision, baseRevision: currentRevision);
266
        }
267 268
      }
    }
269 270 271 272 273 274 275 276 277 278 279 280
  }

  /// Returns the string that should be written to the .metadata file.
  String getOutputFileString() {
    String unmanagedFilesString = '';
    for (final String path in unmanagedFiles) {
      unmanagedFilesString += "\n    - '$path'";
    }

    String platformsString = '';
    for (final MapEntry<SupportedPlatform, MigratePlatformConfig> entry in platformConfigs.entries) {
      platformsString += '\n    - platform: ${entry.key.toString().split('.').last}\n      create_revision: ${entry.value.createRevision == null ? 'null' : "${entry.value.createRevision}"}\n      base_revision: ${entry.value.baseRevision == null ? 'null' : "${entry.value.baseRevision}"}';
281
    }
282 283 284 285 286 287 288 289 290 291 292 293 294 295 296

    return isEmpty ? '' : '''

# Tracks metadata for the flutter migrate command
migration:
  platforms:$platformsString

  # User provided section

  # List of Local paths (relative to this file) that should be
  # ignored by the migrate tool.
  #
  # Files that are not part of the templates will be ignored by default.
  unmanaged_files:$unmanagedFilesString
''';
297 298
  }

299 300 301 302 303
  /// Parses and validates the `migration` section of the .metadata file.
  void parseYaml(YamlMap map, Logger logger) {
    final Object? platformsYaml = map['platforms'];
    if (_validateMetadataMap(map, <String, Type>{'platforms': YamlList}, logger)) {
      if (platformsYaml is YamlList && platformsYaml.isNotEmpty) {
304 305
        for (final YamlMap platformYamlMap in platformsYaml.whereType<YamlMap>()) {
          if (_validateMetadataMap(platformYamlMap, <String, Type>{
306 307 308 309
                'platform': String,
                'create_revision': String,
                'base_revision': String,
              }, logger)) {
310
            final SupportedPlatform platformValue = SupportedPlatform.values.firstWhere(
311 312
              (SupportedPlatform val) => val.toString() == 'SupportedPlatform.${platformYamlMap['platform'] as String}'
            );
313 314
            platformConfigs[platformValue] = MigratePlatformConfig(
              platform: platformValue,
315 316 317 318 319 320 321 322
              createRevision: platformYamlMap['create_revision'] as String?,
              baseRevision: platformYamlMap['base_revision'] as String?,
            );
          } else {
            // malformed platform entry
            continue;
          }
        }
323
      }
324 325 326 327 328
    }
    if (_validateMetadataMap(map, <String, Type>{'unmanaged_files': YamlList}, logger)) {
      final Object? unmanagedFilesYaml = map['unmanaged_files'];
      if (unmanagedFilesYaml is YamlList && unmanagedFilesYaml.isNotEmpty) {
        unmanagedFiles = List<String>.from(unmanagedFilesYaml.value.cast<String>());
329 330 331 332
      }
    }
  }
}
333 334 335

/// Holds the revisions for a single platform for use by the flutter migrate command.
class MigratePlatformConfig {
336 337 338 339 340 341 342 343
  MigratePlatformConfig({
    required this.platform,
    this.createRevision,
    this.baseRevision
  });

  /// The platform this config describes.
  SupportedPlatform platform;
344 345 346 347 348 349 350 351 352 353

  /// The Flutter SDK revision this platform was created by.
  ///
  /// Null if the initial create git revision is unknown.
  final String? createRevision;

  /// The Flutter SDK revision this platform was last migrated by.
  ///
  /// Null if the project was never migrated or the revision is unknown.
  String? baseRevision;
354 355 356 357 358 359

  bool equals(MigratePlatformConfig other) {
    return platform == other.platform &&
           createRevision == other.createRevision &&
           baseRevision == other.baseRevision;
  }
360
}