linkDesktopPubspecFfiOnly function

void linkDesktopPubspecFfiOnly(
  1. List<ModuleInfo> moduleInfos, {
  2. String baseDir = '.',
})

Writes/patches android/consumer-rules.pro with the R8/ProGuard keep rules every Kotlin-on-Android Nitro module needs.

The generated JNI bridge (nitro.<lib>_module.<Module>JniBridge) and the hand-written impl (under the plugin's Android namespace) are reached from C++ via FindClass + GetStaticMethodID by exact name AND signature — R8 has no static-analysis visibility into that, so without these rules it may rename, remove, or (in "full mode") mis-optimize them.

Uses includedescriptorclasses — not a stronger form of a plain -keep, a DIFFERENT protection: plain -keep class X { *; } only protects a member from removal/renaming, not the parameter/return TYPES referenced in its signature. For methods JNI calls by exact signature (every _call trampoline, several of which take many long/data-class params for suspend methods), an altered parameter type produces a VerifyError at the exact call site — a real, previously-hit crash ("VerifyError ... Long (Low Half)"), not a hypothetical one. This is ProGuard's own documented remedy for native/JNI-called methods.

Idempotent and additive: only touches the marked block (creating it if absent), never the rest of the file, so a plugin author's own rules for unrelated dependencies survive every nitrogen link run. Re-derives the block's content from the CURRENT module list each time, so e.g. adding a second Nitro module to the plugin updates the keep rules automatically. Removes pluginClass: from the plugin pubspec's windows:/linux: platform entries when that platform is a Nitro C++ (pure-FFI) backend.

Declaring BOTH pluginClass and ffiPlugin: true for a desktop platform makes Flutter's tooling classify the plugin as method-channel: the app's generated_plugins.cmake then links a <plugin>_plugin CMake target that a pure-FFI plugin never defines — CMake Error: No target <plugin>_plugin — blocking every Windows/Linux build of the consuming app (issue #10). The ffiPlugin-only form routes through FLUTTER_FFI_PLUGIN_LIST and the plugin's <plugin>_bundled_libraries instead, which is what the generated desktop CMakeLists provide.

Handles both YAML styles (block children and inline { ... } flow maps), is idempotent, and touches nothing outside the two desktop entries — android/ios/macos keep their pluginClass (those platforms genuinely register a plugin class).

Implementation

void linkDesktopPubspecFfiOnly(
  List<ModuleInfo> moduleInfos, {
  String baseDir = '.',
}) {
  final pubspecFile = File(p.join(baseDir, 'pubspec.yaml'));
  if (!pubspecFile.existsSync()) return;
  final fixWindows = moduleInfos.any((m) => m.windowsIsCpp);
  final fixLinux = moduleInfos.any((m) => m.linuxIsCpp);
  if (!fixWindows && !fixLinux) return;

  final targets = <String>{if (fixWindows) 'windows', if (fixLinux) 'linux'};
  final lines = pubspecFile.readAsStringSync().split('\n');
  final out = <String>[];
  var inPlatforms = false;
  var platformsIndent = -1;
  var changed = false;

  int indentOf(String l) => l.length - l.trimLeft().length;

  for (var i = 0; i < lines.length; i++) {
    final line = lines[i];
    final trimmed = line.trim();

    if (trimmed == 'platforms:') {
      inPlatforms = true;
      platformsIndent = indentOf(line);
      out.add(line);
      continue;
    }
    if (inPlatforms && trimmed.isNotEmpty && indentOf(line) <= platformsIndent) {
      inPlatforms = false; // left the platforms block
    }

    final flowMatch = inPlatforms ? RegExp(r'^(\s*)(\w+):\s*\{(.*)\}\s*$').firstMatch(line) : null;
    if (flowMatch != null && targets.contains(flowMatch.group(2))) {
      // Inline flow map: windows: { pluginClass: Xxx, ffiPlugin: true }
      final entries = flowMatch.group(3)!.split(',').map((e) => e.trim()).where((e) => e.isNotEmpty).toList();
      if (entries.any((e) => e.startsWith('ffiPlugin:')) && entries.any((e) => e.startsWith('pluginClass:'))) {
        entries.removeWhere((e) => e.startsWith('pluginClass:'));
        out.add('${flowMatch.group(1)}${flowMatch.group(2)}: { ${entries.join(', ')} }');
        changed = true;
        continue;
      }
    }

    final blockKey = inPlatforms ? RegExp(r'^(\s*)(\w+):\s*$').firstMatch(line) : null;
    if (blockKey != null && targets.contains(blockKey.group(2))) {
      // Block form: collect the child lines (strictly deeper indent).
      final keyIndent = indentOf(line);
      var end = i + 1;
      while (end < lines.length && (lines[end].trim().isEmpty || indentOf(lines[end]) > keyIndent)) {
        end++;
      }
      final children = lines.sublist(i + 1, end);
      final hasFfi = children.any((l) => l.trim().startsWith('ffiPlugin:') && l.contains('true'));
      final hasClass = children.any((l) => l.trim().startsWith('pluginClass:'));
      out.add(line);
      for (final child in children) {
        if (hasFfi && hasClass && child.trim().startsWith('pluginClass:')) {
          changed = true;
          continue;
        }
        out.add(child);
      }
      i = end - 1;
      continue;
    }

    out.add(line);
  }

  if (changed) {
    pubspecFile.writeAsStringSync(out.join('\n'));
    stdout.writeln('  pubspec.yaml: removed pluginClass from FFI-only desktop platform entries (fixes "No target <plugin>_plugin")');
  }
}