Create plugin symlinks for Windows and Linux (#50599)

This makes ephemeral symlinks to each plugin, for use by build systems.
This is similar to the logic implemented in the Podfile on iOS and
macOS, but managed internally to the Flutter tool.

Exploration for addressing #32719 and #32720
Related to #41146
diff --git a/packages/flutter_tools/lib/src/linux/build_linux.dart b/packages/flutter_tools/lib/src/linux/build_linux.dart
index 48a9a94..286a332 100644
--- a/packages/flutter_tools/lib/src/linux/build_linux.dart
+++ b/packages/flutter_tools/lib/src/linux/build_linux.dart
@@ -9,6 +9,7 @@
 import '../build_info.dart';
 import '../cache.dart';
 import '../globals.dart' as globals;
+import '../plugins.dart';
 import '../project.dart';
 import '../reporting/reporting.dart';
 
@@ -38,6 +39,7 @@
   linuxProject.generatedMakeConfigFile
     ..createSync(recursive: true)
     ..writeAsStringSync(buffer.toString());
+  createPluginSymlinks(linuxProject.project);
 
   if (!buildInfo.isDebug) {
     const String warning = '🚧 ';
diff --git a/packages/flutter_tools/lib/src/plugins.dart b/packages/flutter_tools/lib/src/plugins.dart
index 3e71a61..cde8cdd 100644
--- a/packages/flutter_tools/lib/src/plugins.dart
+++ b/packages/flutter_tools/lib/src/plugins.dart
@@ -318,6 +318,12 @@
   return plugins;
 }
 
+// Key strings for the .flutter-plugins-dependencies file.
+const String _kFlutterPluginsPluginListKey = 'plugins';
+const String _kFlutterPluginsNameKey = 'name';
+const String _kFlutterPluginsPathKey = 'path';
+const String _kFlutterPluginsDependenciesKey = 'dependencies';
+
   /// Filters [plugins] to those supported by [platformKey].
   List<Map<String, dynamic>> _filterPluginsByPlatform(List<Plugin>plugins, String platformKey) {
     final Iterable<Plugin> platformPlugins = plugins.where((Plugin p) {
@@ -328,9 +334,9 @@
     final List<Map<String, dynamic>> list = <Map<String, dynamic>>[];
     for (final Plugin plugin in platformPlugins) {
       list.add(<String, dynamic>{
-        'name': plugin.name,
-        'path': globals.fsUtils.escapePath(plugin.path),
-        'dependencies': <String>[...plugin.dependencies.where(pluginNames.contains)],
+        _kFlutterPluginsNameKey: plugin.name,
+        _kFlutterPluginsPathKey: globals.fsUtils.escapePath(plugin.path),
+        _kFlutterPluginsDependenciesKey: <String>[...plugin.dependencies.where(pluginNames.contains)],
       });
     }
     return list;
@@ -411,7 +417,7 @@
   final Map<String, dynamic> result = <String, dynamic> {};
 
   result['info'] =  'This is a generated file; do not edit or check into version control.';
-  result['plugins'] = pluginsMap;
+  result[_kFlutterPluginsPluginListKey] = pluginsMap;
   /// The dependencyGraph object is kept for backwards compatibility, but
   /// should be removed once migration is complete.
   /// https://github.com/flutter/flutter/issues/48918
@@ -889,6 +895,72 @@
   }
 }
 
+/// For each platform that uses them, creates symlinks within the platform
+/// directory to each plugin used on that platform.
+///
+/// If |force| is true, the symlinks will be recreated, otherwise they will
+/// be created only if missing.
+///
+/// This uses [project.flutterPluginsDependenciesFile], so it should only be
+/// run after refreshPluginList has been run since the last plugin change.
+void createPluginSymlinks(FlutterProject project, {bool force = false}) {
+  Map<String, dynamic> platformPlugins;
+  final String pluginFileContent = _readFileContent(project.flutterPluginsDependenciesFile);
+  if (pluginFileContent != null) {
+    final Map<String, dynamic> pluginInfo = json.decode(pluginFileContent) as Map<String, dynamic>;
+    platformPlugins = pluginInfo[_kFlutterPluginsPluginListKey] as Map<String, dynamic>;
+  }
+  platformPlugins ??= <String, dynamic>{};
+
+  if (featureFlags.isWindowsEnabled && project.windows.existsSync()) {
+    _createPlatformPluginSymlinks(
+      project.windows.pluginSymlinkDirectory,
+      platformPlugins[project.windows.pluginConfigKey] as List<dynamic>,
+      force: force,
+    );
+  }
+  if (featureFlags.isLinuxEnabled && project.linux.existsSync()) {
+    _createPlatformPluginSymlinks(
+      project.linux.pluginSymlinkDirectory,
+      platformPlugins[project.linux.pluginConfigKey] as List<dynamic>,
+      force: force,
+    );
+  }
+}
+
+/// Creates [symlinkDirectory] containing symlinks to each plugin listed in [platformPlugins].
+///
+/// If [force] is true, the directory will be created only if missing.
+void _createPlatformPluginSymlinks(Directory symlinkDirectory, List<dynamic> platformPlugins, {bool force = false}) {
+  if (force && symlinkDirectory.existsSync()) {
+    // Start fresh to avoid stale links.
+    symlinkDirectory.deleteSync(recursive: true);
+  }
+  symlinkDirectory.createSync(recursive: true);
+  if (platformPlugins == null) {
+    return;
+  }
+  for (final Map<String, dynamic> pluginInfo in platformPlugins.cast<Map<String, dynamic>>()) {
+    final String name = pluginInfo[_kFlutterPluginsNameKey] as String;
+    final String path = pluginInfo[_kFlutterPluginsPathKey] as String;
+    final Link link = symlinkDirectory.childLink(name);
+    if (link.existsSync()) {
+      continue;
+    }
+    try {
+      link.createSync(path);
+    } on FileSystemException catch (e) {
+      if (globals.platform.isWindows && (e.osError?.errorCode ?? 0) == 1314) {
+        throwToolExit(
+          'Building with plugins requires symlink support. '
+          'Please enable Developer Mode in your system settings.\n\n$e'
+        );
+      }
+      rethrow;
+    }
+  }
+}
+
 /// Rewrites the `.flutter-plugins` file of [project] based on the plugin
 /// dependencies declared in `pubspec.yaml`.
 ///
@@ -905,6 +977,7 @@
 
   final bool changed = _writeFlutterPluginsList(project, plugins);
   if (changed || legacyChanged) {
+    createPluginSymlinks(project, force: true);
     if (!checkProjects || project.ios.existsSync()) {
       cocoaPods.invalidatePodInstallOutput(project.ios);
     }
diff --git a/packages/flutter_tools/lib/src/project.dart b/packages/flutter_tools/lib/src/project.dart
index d227d8e..6570e91 100644
--- a/packages/flutter_tools/lib/src/project.dart
+++ b/packages/flutter_tools/lib/src/project.dart
@@ -963,6 +963,9 @@
   /// Ideally this will be replaced in the future with inspection of the project.
   File get nameFile => ephemeralDirectory.childFile('exe_filename');
 
+  /// The directory to write plugin symlinks.
+  Directory get pluginSymlinkDirectory => ephemeralDirectory.childDirectory('.plugin_symlinks');
+
   Future<void> ensureReadyForPlatformSpecificTooling() async {}
 }
 
@@ -997,6 +1000,9 @@
   /// the build.
   File get generatedMakeConfigFile => ephemeralDirectory.childFile('generated_config.mk');
 
+  /// The directory to write plugin symlinks.
+  Directory get pluginSymlinkDirectory => ephemeralDirectory.childDirectory('.plugin_symlinks');
+
   Future<void> ensureReadyForPlatformSpecificTooling() async {}
 }
 
diff --git a/packages/flutter_tools/lib/src/windows/build_windows.dart b/packages/flutter_tools/lib/src/windows/build_windows.dart
index d4b3c50..58d2f5f 100644
--- a/packages/flutter_tools/lib/src/windows/build_windows.dart
+++ b/packages/flutter_tools/lib/src/windows/build_windows.dart
@@ -9,6 +9,7 @@
 import '../build_info.dart';
 import '../cache.dart';
 import '../globals.dart' as globals;
+import '../plugins.dart';
 import '../project.dart';
 import '../reporting/reporting.dart';
 import 'msbuild_utils.dart';
@@ -39,6 +40,7 @@
     environment['LOCAL_ENGINE'] = globals.fs.path.basename(engineOutPath);
   }
   writePropertySheet(windowsProject.generatedPropertySheetFile, environment);
+  createPluginSymlinks(windowsProject.project);
 
   final String vcvarsScript = visualStudio.vcvarsPath;
   if (vcvarsScript == null) {
diff --git a/packages/flutter_tools/test/general.shard/plugins_test.dart b/packages/flutter_tools/test/general.shard/plugins_test.dart
index 88e7b97..f3b669e 100644
--- a/packages/flutter_tools/test/general.shard/plugins_test.dart
+++ b/packages/flutter_tools/test/general.shard/plugins_test.dart
@@ -73,10 +73,12 @@
       windowsProject = MockWindowsProject();
       when(flutterProject.windows).thenReturn(windowsProject);
       when(windowsProject.pluginConfigKey).thenReturn('windows');
+      when(windowsProject.pluginSymlinkDirectory).thenReturn(flutterProject.directory.childDirectory('windows').childDirectory('symlinks'));
       when(windowsProject.existsSync()).thenReturn(false);
       linuxProject = MockLinuxProject();
       when(flutterProject.linux).thenReturn(linuxProject);
       when(linuxProject.pluginConfigKey).thenReturn('linux');
+      when(linuxProject.pluginSymlinkDirectory).thenReturn(flutterProject.directory.childDirectory('linux').childDirectory('symlinks'));
       when(linuxProject.existsSync()).thenReturn(false);
 
       when(mockClock.now()).thenAnswer(
@@ -826,6 +828,138 @@
         FeatureFlags: () => featureFlags,
       });
     });
+
+    group('createPluginSymlinks', () {
+      MockFeatureFlags featureFlags;
+
+      setUp(() {
+        featureFlags = MockFeatureFlags();
+        when(featureFlags.isLinuxEnabled).thenReturn(true);
+        when(featureFlags.isWindowsEnabled).thenReturn(true);
+      });
+
+      testUsingContext('Symlinks are created for Linux plugins', () {
+        when(linuxProject.existsSync()).thenReturn(true);
+        configureDummyPackageAsPlugin();
+        // refreshPluginsList should call createPluginSymlinks.
+        refreshPluginsList(flutterProject);
+
+        expect(linuxProject.pluginSymlinkDirectory.childLink('apackage').existsSync(), true);
+      }, overrides: <Type, Generator>{
+        FileSystem: () => fs,
+        ProcessManager: () => FakeProcessManager.any(),
+        FeatureFlags: () => featureFlags,
+      });
+
+      testUsingContext('Symlinks are created for Windows plugins', () {
+        when(windowsProject.existsSync()).thenReturn(true);
+        configureDummyPackageAsPlugin();
+        // refreshPluginsList should call createPluginSymlinks.
+        refreshPluginsList(flutterProject);
+
+        expect(windowsProject.pluginSymlinkDirectory.childLink('apackage').existsSync(), true);
+      }, overrides: <Type, Generator>{
+        FileSystem: () => fs,
+        ProcessManager: () => FakeProcessManager.any(),
+        FeatureFlags: () => featureFlags,
+      });
+
+      testUsingContext('Existing symlinks are removed when no longer in use with force', () {
+        when(linuxProject.existsSync()).thenReturn(true);
+        when(windowsProject.existsSync()).thenReturn(true);
+
+        final List<File> dummyFiles = <File>[
+          flutterProject.linux.pluginSymlinkDirectory.childFile('dummy'),
+          flutterProject.windows.pluginSymlinkDirectory.childFile('dummy'),
+        ];
+        for (final File file in dummyFiles) {
+          file.createSync(recursive: true);
+        }
+
+        createPluginSymlinks(flutterProject, force: true);
+
+        for (final File file in dummyFiles) {
+          expect(file.existsSync(), false);
+        }
+      }, overrides: <Type, Generator>{
+        FileSystem: () => fs,
+        ProcessManager: () => FakeProcessManager.any(),
+        FeatureFlags: () => featureFlags,
+      });
+
+      testUsingContext('Existing symlinks are removed automatically on refresh when no longer in use', () {
+        when(linuxProject.existsSync()).thenReturn(true);
+        when(windowsProject.existsSync()).thenReturn(true);
+
+        final List<File> dummyFiles = <File>[
+          flutterProject.linux.pluginSymlinkDirectory.childFile('dummy'),
+          flutterProject.windows.pluginSymlinkDirectory.childFile('dummy'),
+        ];
+        for (final File file in dummyFiles) {
+          file.createSync(recursive: true);
+        }
+
+        // refreshPluginsList should remove existing links and recreate on changes.
+        configureDummyPackageAsPlugin();
+        refreshPluginsList(flutterProject);
+
+        for (final File file in dummyFiles) {
+          expect(file.existsSync(), false);
+        }
+      }, overrides: <Type, Generator>{
+        FileSystem: () => fs,
+        ProcessManager: () => FakeProcessManager.any(),
+        FeatureFlags: () => featureFlags,
+      });
+
+      testUsingContext('createPluginSymlinks is a no-op without force when up to date', () {
+        when(linuxProject.existsSync()).thenReturn(true);
+        when(windowsProject.existsSync()).thenReturn(true);
+
+        final List<File> dummyFiles = <File>[
+          flutterProject.linux.pluginSymlinkDirectory.childFile('dummy'),
+          flutterProject.windows.pluginSymlinkDirectory.childFile('dummy'),
+        ];
+        for (final File file in dummyFiles) {
+          file.createSync(recursive: true);
+        }
+
+        // Without force, this should do nothing to existing files.
+        createPluginSymlinks(flutterProject);
+
+        for (final File file in dummyFiles) {
+          expect(file.existsSync(), true);
+        }
+      }, overrides: <Type, Generator>{
+        FileSystem: () => fs,
+        ProcessManager: () => FakeProcessManager.any(),
+        FeatureFlags: () => featureFlags,
+      });
+
+      testUsingContext('createPluginSymlinks repairs missing links', () {
+        when(linuxProject.existsSync()).thenReturn(true);
+        when(windowsProject.existsSync()).thenReturn(true);
+        configureDummyPackageAsPlugin();
+        refreshPluginsList(flutterProject);
+
+        final List<Link> links = <Link>[
+          linuxProject.pluginSymlinkDirectory.childLink('apackage'),
+          windowsProject.pluginSymlinkDirectory.childLink('apackage'),
+        ];
+        for (final Link link in links) {
+          link.deleteSync();
+        }
+        createPluginSymlinks(flutterProject);
+
+        for (final Link link in links) {
+          expect(link.existsSync(), true);
+        }
+      }, overrides: <Type, Generator>{
+        FileSystem: () => fs,
+        ProcessManager: () => FakeProcessManager.any(),
+        FeatureFlags: () => featureFlags,
+      });
+    });
   });
 }