diff --git a/rfc/510.0000-targeting-custom-embedders-with-flutter-tools.md b/rfc/510.0000-targeting-custom-embedders-with-flutter-tools.md new file mode 100644 index 0000000..569dbb2 --- /dev/null +++ b/rfc/510.0000-targeting-custom-embedders-with-flutter-tools.md @@ -0,0 +1,2403 @@ +--- +type: rfc +rfc: '510.0000' +title: Targeting Custom Embedders with Flutter Tools +description: > + Proposes a first-class, isolate-based platform extension mechanism for + the Flutter CLI (flutter_tools) that allows custom and embedded targets to + integrate seamlessly with standard Flutter workflows without forking the + Flutter SDK. +status: draft +created: 2026-10-27T00:00:00Z +updated: 2026-11-10T01:20:00Z +tags: + - 510-tool + - 450-embedded + - 440-plugins +authors: + - https://github.com/bkonyi +--- + +# RFC 510.0000: Targeting Custom Embedders with Flutter Tools + +## Summary + +This RFC proposes a first-class, isolate-based platform extension architecture for the Flutter CLI (`flutter_tools`). By decoupling target platform tooling from the core Flutter SDK into project-scoped Dart packages (`package:flutter_tools_extension` and `package:flutter_tools_core`) that communicate with the Flutter Tool over a structured JSON-RPC 2.0 protocol, third-party and custom embedder authors can hook their devices, build targets, platform templates, SDK artifacts, and diagnostics directly into standard `flutter` commands (`doctor`, `config`, `create`, `devices`, `emulators`, `run`, `attach`, `logs`, `install`, `screenshot`, `drive`, `test`, `build`, `assemble`, `precache`, and `clean`) without forking the Flutter repository or maintaining bespoke wrapper CLIs. + +## Overview + +Custom Flutter embeddings (such as WebOS, Tizen, automotive head units, and industrial displays) currently lack first-class access to the standard functionality of the `flutter` command-line tool. While the C/C++ Embedder API provides a stable boundary for integrating the Flutter runtime on non-standard operating systems, the developer tooling in `flutter_tools` has historically hard-coded assumptions around its six first-party target platforms (Android, iOS, web, Linux, macOS, and Windows). + +Consequently, embedder maintainers have been stuck between two difficult choices: + +1. **Maintaining a fork of the Flutter SDK or a bespoke wrapper CLI** (`flutter-elinux`, `flutter-tizen`, `flutter-pi` tooling) that duplicates command routing, argument parsing, and asset bundling while constantly drifting behind upstream SDK releases and breaking standard IDE integrations. +2. **Using `flutter custom-devices`**, which substitutes shell commands for basic device discovery, installation, and port forwarding via a static JSON configuration file, but cannot express custom build pipelines (`flutter build`, `flutter assemble`), platform scaffolding (`flutter create`), SDK artifact management (`flutter precache`), environment diagnostics (`flutter doctor`), or native plugin compilation. + +To support custom embedders and target platforms without forks, the Flutter Tool needs to be pluggable, providing embedder maintainers a way to hook into existing commands that require knowledge of the target platform. To achieve this, the Flutter Tool will support project-scoped tool extensions (simply referred to as "extensions" throughout the rest of this document) that run in separate Dart isolates and provide platform-specific implementations for key operations performed by the tool. + +Once implemented, the Tool Extension API will allow extensions to provide support for: + +* Downloading and caching necessary artifacts for the target platform (e.g., custom embeddings of the Flutter engine or cross-compilation toolchains) via `flutter precache` +* Interacting with custom devices (e.g., detection via `flutter devices`, deployment and hot reload via `flutter run` and `flutter attach`, log streaming via `flutter logs`, standalone installation via `flutter install`, screenshot capture via `flutter screenshot`, and end-to-end testing via `flutter drive` and `flutter test integration_test -d `) +* Selecting out-of-tree target devices and launching, hot reloading, and debugging applications directly from IDEs (VS Code and IntelliJ/Android Studio) via `flutter daemon` without custom IDE plugins +* Listing, launching, and creating device emulators (`--create`) via `flutter emulators` +* Registering high-level `flutter build ` subcommands and fine-grained `flutter assemble` build targets to handle compilation and bundling steps specific to the target platform +* Running custom `flutter doctor` checks to verify that the host build environment is set up for the target platform +* Registering custom feature flags and configuration options for use with `flutter config` +* Scaffolding platform runner directories for applications and plugins via `flutter create . --platforms=` +* Cleaning up extension-specific build outputs during `flutter clean` + +### Non-Goals + +* **Security Sandboxing**: This design focuses on process stability and isolating the tool from uncaught Dart exceptions in buggy extensions, not on providing an OS-level security sandbox for executing untrusted code (see [Section 3.3](#33-isolate-isolation-boundaries--process-wide-constraints)). Once dependencies are resolved via `flutter pub get`, extensions run with the same permissions as the Flutter Tool, matching the trust model of Dart build hooks (`package:hooks`) and analyzer plugins. +* **Non-Dart Extensions**: This proposal only supports extensions written in Dart to leverage the isolate-based communication model and shared packages. +* **Universal Dart CLI Extensibility**: While the underlying isolate JSON-RPC runner is generic, the scope of this document is limited to the Flutter Tool and does not aim to provide a general plugin system for all Dart CLI tools. (See [Out of Scope & Future Work](#out-of-scope--future-work) for follow-up first-party platform migration and `flutter custom-devices` deprecation plans.) + +--- + +## Usage Examples + +### Example 1: Implementing a Custom Embedder Extension + +An embedder maintainer creates a Dart package (e.g., `package:flutter_tools_extension_custom_embedder`), implements the relevant services from `package:flutter_tools_extension`, and exposes the conventional `extension/flutter_tools/extension.dart` isolate entrypoint. + +```dart +// extension/flutter_tools/extension.dart +import 'package:flutter_tools_extension/flutter_tools_extension.dart'; + +/// Builds a Flutter application bundle for the custom embedder target. +final class CustomEmbedderBuildTarget extends BuildTarget { + const CustomEmbedderBuildTarget() + : super( + description: 'Build a custom embedder bundle.', + outputDir: + '${BuiltInPlaceholders.outputDir}/custom_embedder/${BuiltInPlaceholders.buildMode}', + targetOs: 'linux', + targetPlatform: 'custom-arm64', + ); + + /// The subcommand name registered under `flutter build`. + @override + String get name => 'custom-embedder'; + + /// Compiles and packages the application bundle inside + /// [ExtensionBuildContext.outputDir]. + @override + Future build(ExtensionBuildContext context) async { + final Uri bundleDir = context.outputDir.resolve('bundle/'); + return ExtensionBuildResult(executableUri: bundleDir); + } +} + +/// Exposes custom embedder build targets to the Flutter Tool. +final class CustomEmbedderBuildService extends BuildService { + /// High-level `flutter build` targets provided by this extension. + @override + List get buildTargets => const [ + CustomEmbedderBuildTarget(), + ]; +} + +/// Discovers and manages connected custom embedder hardware. +final class CustomEmbedderDeviceService extends DeviceService { + /// Queries device tooling or scans the network for target hardware. + @override + Future> getDevices() async { + return const [ + TargetDevice( + buildTarget: 'custom-embedder', + category: .mobile, + ephemeral: true, + id: 'embedder-01', + name: 'Custom Embedder Reference Board', + sdkNameAndVersion: 'CustomOS 2.1', + supportsInstall: false, + supportsScreenshot: false, + supportsScreenRecording: false, + targetPlatform: 'custom-arm64', + ), + ]; + } + + /// Checks whether [project] contains a `custom_embedder/` runner directory. + @override + Future isSupportedForProject({ + required String deviceId, + required ExtensionFlutterProject project, + }) async { + return true; + } + + /// Installs and launches [executableUri] on [deviceId] and forwards the + /// VM Service port to the host machine. + @override + Future launchApp({ + required String appId, + required ExtensionDebuggingOptions debuggingOptions, + required String deviceId, + required Uri executableUri, + bool prebuiltApplication = false, + }) async {} + + /// Returns the host-forwarded HTTP VM Service URI for hot reload and + /// DevTools. + @override + Future getVmServiceUri({ + required String deviceId, + String? appId, + int? deviceVmServicePort, + int? hostVmServicePort, + }) async { + return Uri.parse('http://127.0.0.1:41235/abcd1234efgh=/'); + } + + /// Terminates the running application and tears down port forwarding. + @override + Future stopApp({required String deviceId, String? appId}) async {} +} + +/// Adds `--platforms=custom_embedder` support to `flutter create`. +final class CustomEmbedderTemplateService extends TemplateService { + /// Maps the `custom_embedder` platform identifier to its template directory. + @override + Map get appPlatformTemplates => const { + 'custom_embedder': + 'package:flutter_tools_extension_custom_embedder/templates/custom_embedder', + }; +} + +/// Isolate entrypoint spawned by the Flutter Tool via `Isolate.spawnUri`. +Future main(List args, Object? message) async { + await ToolExtensionEntryPoint.run( + message, + [ + CustomEmbedderBuildService(), + CustomEmbedderDeviceService(), + CustomEmbedderTemplateService(), + ], + supportedHostPlatforms: { + .darwin_arm64, + .darwin_x64, + .linux_arm64, + .linux_x64, + }, + ); +} +``` + +### Example 2: CLI Workflow for App Developers + +Once the extension is published to `pub.dev` or available locally in a workspace, application developers use standard `flutter` CLI commands: + +```sh +# 1. Enable the experimental tool extensions feature flag. +$ flutter config --enable-tool-extensions + +# 2. Create a Flutter project without default platform runners and add the +# embedder extension. +$ flutter create --no-platforms my_embedded_app +$ cd my_embedded_app +$ flutter pub add --dev flutter_tools_extension_custom_embedder + +# 3. Scaffold the custom platform runner directory in place. +$ flutter create . --platforms=custom_embedder + +# 4. Verify environment readiness and connected target hardware. +$ flutter doctor +$ flutter devices + +# 5. Run with hot reload, build a release bundle, and clean build outputs. +$ flutter run -d embedder-01 +$ flutter build custom-embedder --release +$ flutter clean +``` + +--- + +## Detailed Design + +### 1. Package Boundaries & Workspace Layout + +The extension framework splits contracts across three packages in the Flutter repository workspace: + +```mermaid +flowchart LR + subgraph Host["Host CLI Process"] + direction TB + FT["package:flutter_tools"] + FTC1["package:flutter_tools_core"] + FT --> FTC1 + end + + subgraph Iso["Extension Isolate (Isolate.spawnUri)"] + direction TB + EXT["3P Extension Package"] + FTE["package:flutter_tools_extension"] + FTC2["package:flutter_tools_core"] + EXT --> FTE + EXT --> FTC2 + FTE --> FTC2 + end + + Host <-->|"JSON-RPC 2.0 over IsolateChannel"| Iso +``` + +1. **`package:flutter_tools_core`** (`packages/flutter_tools/packages/flutter_tools_core/`): + * **Shared by both the Flutter Tool and extensions**: Contains pure, immutable wire models and enums for devices and emulators, build and assemble targets, doctor diagnostics, configuration flags, SDK artifacts, and capability handshake metadata, imported directly by built-in `package:flutter_tools` subsystems (doctor, device, build, and project models) as well as third-party extensions. + * Depends only on `package:collection` and `package:meta` (with zero `dart:io` or `dart:ffi` imports) so it can be published to `pub.dev` without pulling internal `flutter_tools` implementation details into third-party packages. +2. **`package:flutter_tools_extension`** (`packages/flutter_tools/packages/flutter_tools_extension/`): + * **For extensions only (not imported by `package:flutter_tools`)**: Re-exports `package:flutter_tools_core` and provides the extension-author SDK, including the `abstract base class` service interfaces, build and assemble execution contexts, `ToolExtensionEntryPoint` isolate runner, injectable host-like dependencies (file system, logger, and process manager), and extension testing utilities (`package:flutter_tools_extension/testing.dart`). +3. **`package:flutter_tools`** (`packages/flutter_tools/`): + * **For the host CLI only**: Implements host-side extension discovery, kernel snapshot compilation and caching, isolate lifecycle management, JSON-RPC client wrappers, and adapters bridging extension services into native Flutter Tool subsystems (doctor, config, templates, devices, build system, artifacts, and clean). + +Splitting `package:flutter_tools_core` from `package:flutter_tools_extension` allows `package:flutter_tools` to share canonical data models (`ValidationResult`, `HostPlatform`, `BuildMode`, `Category`, etc.) with extensions without taking a dependency on extension-side runtime machinery (`ToolExtensionEntryPoint`, `*Service` base classes, or `testing.dart`), while keeping extensions insulated from internal `package:flutter_tools` APIs. Both `package:flutter_tools_core` and `package:flutter_tools_extension` are published to `pub.dev` and resolved via standard semantic versioning rather than being pinned to the exact version shipped inside the user's Flutter SDK. Because extensions run in separate isolates and communicate with the Flutter Tool over a backwards-compatible JSON-RPC 2.0 wire protocol (rather than sharing in-memory Dart class hierarchies with `package:flutter_tools`), an extension compiled against an older minor version of `package:flutter_tools_extension` remains compatible with newer Flutter SDKs even when newer SDKs add methods, fields, or parameters to the service interfaces. + +--- + +### 2. Project-Scoped Discovery & Two-Tier Kernel Compilation + +#### 2.1 Project-Scoped Discovery via `pubspec.yaml` + +Following the precedent established by DevTools extensions, extensions are shipped as standard Dart packages and declared in a project's `pubspec.yaml` (typically under `dev_dependencies`). When the Flutter Tool runs inside a project directory, it reads direct `dependencies` and `dev_dependencies` from the active project's `pubspec.yaml` (and the Pub workspace root `pubspec.yaml`, if the project participates in a workspace), resolves only those direct dependencies' package roots in `.dart_tool/package_config.json` (`package:package_config`), and discovers any direct dependency containing `extension/flutter_tools/extension.dart` (verifying after symlink resolution that the entrypoint resides within the package root). This enables teams to lock extension versions in version control and execute reproducible builds across developer machines and CI environments with a standard `flutter pub get`. + +To prevent unvetted transitive dependencies from executing code automatically and avoid running host-specific service initialization on unsupported hosts, the tool enforces several safeguards during discovery: + +* **Direct Dependency Filtering**: The tool inspects the active project's `pubspec.yaml` (and the Pub workspace root `pubspec.yaml`, if the project participates in a workspace) and activates only extensions listed in direct `dependencies` or `dev_dependencies`. +* **Host Platform & Service Capability Filtering**: Because `dart compile kernel` is platform-independent Dart front-end compilation, the tool compiles an extension snapshot once per cache key and caches its handshake `ToolExtensionCapabilities` (`services` and optional `supportedHostPlatforms`). Extension `main()` entrypoints and `*Service` constructors must be side-effect-free, deferring any host-specific work to service RPC handlers (`getDevices`, `runDiagnostics`, `build`, etc.). When `supportedHostPlatforms` is non-null and excludes the current `HostPlatform`, `ToolExtensionEntryPoint.run` skips `service.initialize()`, completes the `extension.getCapabilities` handshake so the host records the cached capabilities, and shuts down cleanly (logging at trace level only). On subsequent CLI invocations, the tool skips spawning isolates whose cached `services` do not apply to the active command or whose cached `supportedHostPlatforms` excludes the current host. +* **Safe Mode Bypass**: To ensure users can recover from broken or misbehaving extensions, passing `--no-extensions` (`--[no-]extensions`) on the command line or setting `FLUTTER_NO_EXTENSIONS=1` (`true` or `yes`) in the environment skips extension discovery and isolate spawning for the invocation. +* **`pub get` Lifecycle Ordering**: Commands that register dynamic CLI flags or subcommands from extensions ensure `.dart_tool/package_config.json` is up to date before querying available extensions, and refresh discovery if `pub get` resolves new packages during command startup. +* **Long-Lived Session & Multi-Project Invalidation (`flutter daemon`)**: In long-lived sessions such as `flutter daemon`, where the initial working directory may be a multi-root workspace parent and dependencies can change mid-session (`flutter pub add --dev`), the daemon discovers extensions from the initial working directory plus any `projectDirectory` roots registered via `app.start` (or workspace roots), refreshes discovery whenever `.dart_tool/package_config.json` changes in any tracked root, and deduplicates discovered extensions across tracked roots by extension package name (if two tracked workspace roots resolve different versions of the same extension package, the first registered root wins for global `device.enable` polling and the tool logs a warning). Launching an application via `app.start` for a `projectDirectory` that does not declare that extension in its own `pubspec.yaml` remains gated by project dependency membership and `isSupportedForProject`. + +#### 2.2 Entrypoint Convention & Capability Registration (`extension/flutter_tools/extension.dart`) + +Every extension package exposes `extension/flutter_tools/extension.dart` at its package root with the isolate entrypoint signature `Future main(List args, Object? message)`. Placing the entrypoint under `extension/flutter_tools/extension.dart` scopes the extension to `flutter_tools` (matching the `extension//` directory layout used by DevTools extensions) without requiring a separate YAML manifest file, while keeping `bin/` reserved for actual CLI executables (preventing broken `dart run` invocations on `SendPort` scripts and preventing `pub.dev` / `pana` from falsely tagging tool extensions as `has:executable`). + +Service and host-platform registration happen in one place only in typed Dart with dartdoc: the `List` and optional `Set? supportedHostPlatforms` passed to `ToolExtensionEntryPoint.run` (see [Section 4.1](#41-core-service-entrypoint--error-abstractions)), while build subcommand names, custom template platforms, and custom CLI flags and options are declared once in Dart on `BuildTarget`, `TemplateService`, and `ConfigService`. `supportedHostPlatforms` defaults to `null` (meaning all host platforms are supported), and passing a non-null empty set (`{}`) throws an `ArgumentError` (and fails `verifyExtensionPackage`) so an extension cannot accidentally disable itself on all hosts. Both `services` and `supportedHostPlatforms` must depend **only on the extension package snapshot** (never branching on environment variables, `PATH` or installed host toolchains, current working directory, or project contents; host environment and project checks belong in `DiagnosticsService`, `DeviceService.getDevices`, and `isSupportedForProject`). After an extension's first handshake (`extension.getCapabilities`; see [Section 3.1](#31-json-rpc-20-transport-handshake--exit-monitoring)), the tool caches its `ToolExtensionCapabilities` (`services` and `supportedHostPlatforms`) alongside the compiled `.dill` snapshot (see [Section 2.3](#23-two-tier-kernel-dill-compilation-cache)) so subsequent CLI invocations skip spawning isolates whose cached `services` do not apply to the active command or whose cached `supportedHostPlatforms` excludes the current host. + +#### 2.3 Two-Tier Kernel (`.dill`) Compilation Cache + +Because `Isolate.spawnUri` requires either a Dart source URI with a resolved package config or a precompiled Dart kernel (`.dill`) snapshot, the Flutter Tool compiles extension entrypoints with `dart compile kernel` into a two-tier cache to keep startup fast: + +1. **Tier 1: Shared Cache for Immutable (`hosted` / `git`) Extensions**: + * **Eligibility**: An extension is eligible for Tier 1 only when both the extension package and its entire transitive dependency closure resolve from immutable sources in `pubspec.lock` (`hosted` or `git`, with no `path:` dependencies or mutable `dependency_overrides`); otherwise it falls back to Tier 2 so edits to a transitive `path:` dependency never leave a stale shared `.dill`. + * **Location & Cached Capabilities**: Stored under `$DART_DATA_HOME/flutter_tool_extensions/cache` (resolved using `package:dart_data_home` to follow standard per-OS user state directories) as `--.dill`, alongside `--.capabilities.json` written after the first handshake (`services` and `supportedHostPlatforms`). + * **Dart SDK & Dependency Graph Cache Keying**: Compiled kernel `.dill` snapshots are tied to the exact Dart VM binary format and embed the consuming project's resolved transitive dependencies (`flutter_tools_extension`, `flutter_tools_core`, `json_rpc_2`, etc.). Two projects on the same machine may depend on the same extension version while resolving different transitive package versions, different `git` commits sharing a `pubspec.yaml` version, or different Flutter SDK versions (for example, via `fvm`). Keying Tier 1 artifacts by `--`, hashing the exact Dart SDK version and revision (`Platform.version` / `bin/cache/dart-sdk/revision`) together with the resolved package versions and resolved `git` commit SHAs / `.dart_tool/package_config.json` `rootUri` locations of all direct and transitive dependencies in the extension's closure from `.dart_tool/package_graph.json` and `.dart_tool/package_config.json`, allows `fvm` and multi-SDK workflows to coexist in `$DART_DATA_HOME/flutter_tool_extensions/cache` without wiping cached `.dill` snapshots. + * **Concurrency**: Protected across concurrent `flutter` invocations using cross-process file locking and atomic temporary-file renames. +2. **Tier 2: Project-Local Cache for Mutable (`path:` / Workspace) Extensions**: + * **Location**: `/.dart_tool/flutter_tools/extensions//extension.dill` alongside `extension.dill.d` and `extension.dill.stamp`. + * **Invalidation & Capability Freshness Ordering**: Alongside `extension.dill`, the tool writes an `extension.dill.stamp` file recording the exact Dart SDK version and revision hash, the resolved dependency closure hash, and (after handshake) the extension's cached `ToolExtensionCapabilities` (`services` and `supportedHostPlatforms`). During discovery, the host runs the Tier 2 freshness check (`extension.dill.stamp` SDK/dependency hash plus `.dill.d` source and `.dart_tool/package_config.json` modification timestamps) **before** applying the cached-capability filter, treating stale Tier 2 snapshots as having unknown capabilities. Adding a new `*Service` to a mutable local extension is therefore picked up immediately on the next `flutter doctor` or `flutter build` invocation, and switching to an older SDK via `fvm` (whose SDK files have older modification timestamps than `extension.dill`) deterministically invalidates and recompiles `extension.dill`. + +--- + +### 3. Isolate Transport, Lifecycle & Isolation Boundaries + +#### 3.1 JSON-RPC 2.0 Transport, Handshake & Exit Monitoring + +```mermaid +sequenceDiagram + participant Host as Flutter Tool (Host Isolate) + participant Iso as Extension Isolate (ToolExtensionEntryPoint) + participant Svc as Registered ToolExtensionServices + + Host->>Iso: Isolate.spawnUri(dillUri, [], handshakeSendPort, onError, onExit) + Iso->>Svc: await service.initialize() (skipped if supportedHostPlatforms excludes host) + Note over Iso: Connect IsolateChannel only after fallible setup succeeds + Iso->>Host: IsolateChannel.connectSend(handshakeSendPort) + Iso->>Svc: Wire notificationSender & requestSender to Peer + Note over Host,Iso: Bidirectional IsolateChannel + Peer.withoutJson + Host->>Iso: RPC "extension.getCapabilities" + Iso-->>Host: ToolExtensionCapabilities (services, supportedHostPlatforms) + Host->>Host: Cache capabilities, shut down if host unsupported + Note over Host,Iso: Active Command RPCs & Notifications + Host->>Iso: RPC "extension.shutdown" + Iso->>Svc: await Future.wait(services.map((s) => s.shutdown())) (if initialized) + Iso-->>Host: { "acknowledged": true } + Host->>Iso: peer.close() & isolate.kill(priority: Isolate.immediate) +``` + +Because both endpoints execute inside the same Dart VM process, the Flutter Tool and `ToolExtensionEntryPoint` communicate over a `Peer.withoutJson` backed by an `IsolateChannel`. Structured `Map` payloads cross the isolate port directly without intermediate UTF-8 string encoding or decoding while preserving standard JSON-RPC 2.0 request, response, and notification framing. + +When an extension isolate boots on a supported host platform, `ToolExtensionEntryPoint.run` invokes `await service.initialize()` on every registered service **before** calling `IsolateChannel.connectSend(sendPort)`. Because `connectSend` opens a `ReceivePort` that keeps the isolate alive, deferring channel connection until after all fallible initialization completes ensures that an unhandled exception during `initialize()` allows the isolate to exit immediately. When `supportedHostPlatforms` is non-null and excludes the current host platform, `ToolExtensionEntryPoint.run` skips `service.initialize()`, connects the `IsolateChannel` to serve `extension.getCapabilities` so the host records the cached capabilities, and awaits clean shutdown. On the host side, the tool attaches listeners to `Isolate.spawnUri`'s `onError` and `onExit` ports so unexpected isolate termination immediately fails any pending handshake or in-flight RPCs. + +**Wire Payload Examples**: + +1. **Capability Handshake Request**: Sent by the Flutter Tool to the extension isolate immediately after the `IsolateChannel` connects to query which services and host platforms the extension declares: + + ```json + { + "jsonrpc": "2.0", + "id": 1, + "method": "extension.getCapabilities" + } + ``` + +2. **Capability Handshake Response**: Returned by the extension isolate listing the active service namespaces and optional `supportedHostPlatforms` (serialized as a `List` of `HostPlatform.cliName` values such as `linux-x64`, `linux-arm64`, `linux-riscv64`, `darwin-x64`, `darwin-arm64`, `windows-x64`, or `windows-arm64`, and omitted when unrestricted/`null`): + + ```json + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "services": ["build", "device", "template"], + "supportedHostPlatforms": [ + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64" + ] + } + } + ``` + +3. **Structured Log Notification**: Sent asynchronously by the extension isolate to stream log output to the Flutter Tool's logger: + + ```json + { + "jsonrpc": "2.0", + "method": "extension.log", + "params": { + "level": "info", + "message": "Launching /path/to/bundle on embedder-01..." + } + } + ``` + +#### 3.2 Graceful Shutdown & Subprocess Cleanup + +Extensions frequently spawn OS subprocesses (such as `ssh -L` port-forwarding tunnels, device log readers, or emulators) whose lifecycles must not outlive the `flutter` process: + +* The tool registers extension teardown with its shutdown hooks on CLI exit. +* When shutting down an active extension, the tool sends an `extension.shutdown` RPC to the isolate and awaits completion with a short bounded grace period before closing the JSON-RPC `Peer` and calling `isolate.kill(priority: Isolate.immediate)`, ensuring a wedged extension cannot hang CLI exit. +* `ToolExtensionEntryPoint` handles `extension.shutdown` by invoking `await Future.wait(initializedServices.map((s) => s.shutdown()))` on every initialized service (skipping `shutdown()` when `service.initialize()` was skipped on an excluded host), allowing `DeviceService.shutdown()` and other services to terminate active tunnels and child processes cleanly. +* Because OS child processes belong to the host VM process rather than the child isolate, we also propose introducing `ExtensionProcessManager` as an injectable abstraction (allowing fake implementations to be injected in unit tests via `ExtensionDependencies`; extensions currently invoke `dart:io` `Process` or `package:process` directly) that tracks active background process PIDs and reports them to the host tool, allowing the tool to terminate any remaining child processes via `Process.killPid` if an extension isolate exits unexpectedly. + +#### 3.3 Isolate Isolation Boundaries & Process-Wide Constraints + +While Dart isolates provide separate heaps and prevent uncaught Dart exceptions in an extension from crashing the main tool isolate's event loop, **isolates share the same OS process** as the Flutter Tool. Extension authors and the host runtime must account for four process-wide boundaries: + +1. **`stdout` / `stderr` / `stdin` Stream Integrity**: Commands such as `flutter daemon` and `flutter devices --machine` emit structured JSON protocol frames over `stdout`. Unintercepted writes to `io.stdout` or `print()` calls inside an extension isolate would corrupt the tool's machine output, and reads from `io.stdin` would contend with the host CLI for terminal input. + * **Mitigation**: `ToolExtensionService.log` and `ExtensionLogger` emit structured `extension.log` JSON-RPC notifications that the host forwards to its logger. In addition, `ToolExtensionEntryPoint.run` executes inside both a custom `ZoneSpecification` (intercepting `print()` calls) and `IOOverrides.runZoned` (overriding `stdout`, `stderr`, and `stdin`). Any direct writes to `io.stdout` or `io.stderr` inside the extension isolate are captured by the overridden `stdout` and `stderr` streams and forwarded as `extension.log` JSON-RPC notifications (`level: .info` and `level: .error`), while `io.stdin` is overridden with an empty stream so extensions cannot block waiting on raw terminal input (interactive CLI prompts are instead mediated by the host tool over the bidirectional JSON-RPC `Peer`; see [Section 4.3](#43-configuration-flutter-config)). +2. **Process Exit & Working Directory**: Calling `io.exit()` inside an extension terminates the entire `flutter` process immediately, and mutating `io.Directory.current` alters the process-wide current working directory for the main isolate and sibling extensions. + * **Mitigation**: The `IOOverrides.runZoned` environment installed by `ToolExtensionEntryPoint.run` also overrides `exit` (`exit: (int code) => throw UnsupportedError('Tool extensions must not call exit().')`, using the `IOOverrides.exit` hook added in Dart 3.10) and guards `getCurrentDirectory` / `setCurrentDirectory` against working-directory mutations. Instead of calling `exit()` or mutating `Directory.current`, extensions must throw `ToolExtensionException` on failure, pass explicit `workingDirectory` arguments when spawning processes, and resolve paths against project URIs. +3. **Native Crashes (FFI) and Out-of-Memory**: A segmentation fault in native FFI code loaded by an extension or process-level memory exhaustion will terminate the `flutter` process. +4. **Snapshot Format Compatibility (`Isolate.spawnUri`)**: The snapshot format passed to `Isolate.spawnUri` must match the execution mode of the host Dart runtime. Because the Flutter Tool currently executes on the Dart JIT VM, the tool compiles extensions into kernel (`.dill`) snapshots (`dart compile kernel`). If the Flutter Tool is compiled into a standalone AOT executable (`dart compile exe`), `Isolate.spawnUri` cannot load kernel `.dill` files, but it *can* spawn precompiled AOT snapshots (`dart compile aot-snapshot`) compiled with the matching Dart runtime. (See [Alternatives Considered](#alternatives-considered) for `stdio` subprocess trade-offs.) + +--- + +### 4. Base Service Contracts & Host Integration + +#### 4.1 Core Service, Entrypoint & Error Abstractions + +`package:flutter_tools_extension` provides a set of base classes for the services that can be implemented by an extension. All seven extension services (`DiagnosticsService`, `ConfigService`, `TemplateService`, `DeviceService`, `BuildService`, `ArtifactService`, and `CleanService`), as well as `BuildTarget` and `AssembleTarget`, are declared as `abstract base class`. Restricting `implements` ensures that `package:flutter_tools_extension` can add new defaulted methods in minor releases without breaking existing extensions. + +To replace the current ad-hoc error handling (where extensions throw generic exceptions or `RpcException` directly), this RFC proposes a single structured error model in `package:flutter_tools_core`: operational failures throw `ToolExtensionException` (with default error code `-32001`), optional capabilities that are not supported throw `UnsupportedError` (mapped to JSON-RPC error code `-32002`), and unexpected uncaught exceptions map to `json_rpc_2`'s standard `-32000` (`SERVER_ERROR`), allowing the tool to distinguish actionable domain errors from unhandled extension bugs. + +```dart +// package:flutter_tools_core/src/exception.dart +/// An exception thrown by a tool extension to report an intentional, +/// user-actionable operational failure over JSON-RPC 2.0. +@immutable +class ToolExtensionException implements Exception { + /// Creates a domain exception with a user-facing [message], optional JSON-RPC + /// error [code], and optional structured diagnostic [data]. + const ToolExtensionException( + this.message, { + this.code = domainErrorCode, + this.data, + }); + + /// JSON-RPC error code for intentional, user-actionable domain errors. + /// Distinct from -32000 (`SERVER_ERROR`), which indicates an unhandled + /// exception in the extension isolate. + static const domainErrorCode = -32001; + + /// JSON-RPC error code returned when an optional capability is not supported + /// by the extension. + static const unsupportedCapabilityErrorCode = -32002; + + /// The JSON-RPC 2.0 error code reported to the Flutter Tool. + final int code; + + /// Optional structured error details serialized into the JSON-RPC error data. + final Map? data; + + /// Human-readable error message surfaced by the Flutter Tool. + final String message; +} +``` + +```dart +// package:flutter_tools_extension/src/protocol_base/service.dart +/// Signature for a JSON-RPC 2.0 request handler registered by a +/// [ToolExtensionService]. +typedef ExtensionRpcHandler = FutureOr Function( + Map params, +); + +/// Callback that dispatches a fire-and-forget JSON-RPC 2.0 notification from a +/// [ToolExtensionService] to the host tool. +typedef ExtensionNotificationSender = void Function( + String method, + Map params, +); + +/// Callback that dispatches a JSON-RPC 2.0 request from a +/// [ToolExtensionService] to the host tool over the bidirectional peer. +typedef ExtensionRequestSender = Future Function( + String method, + Map params, +); + +/// Base class for all services exposed by a Flutter Tool extension isolate. +abstract base class ToolExtensionService { + /// JSON-RPC request method used to query the persisted `flutter config` + /// values owned by this extension from the host tool. + static const getConfigurationValuesRequestMethod = + 'extension.getConfigurationValues'; + + /// JSON-RPC notification method used to stream structured log messages to the + /// host tool. + static const logNotificationMethod = 'extension.log'; + + /// JSON-RPC notification method used to report progress for long-running + /// operations. + static const progressNotificationMethod = 'extension.progress'; + + /// JSON-RPC request method used to prompt the user to enter a line of text or + /// a passphrase via the host tool's interactive terminal. + static const promptForInputRequestMethod = 'terminal.promptForInput'; + + /// JSON-RPC request method used to prompt the user to select an option from + /// an interactive terminal menu on the host tool. + static const promptForOptionRequestMethod = 'terminal.promptForOption'; + + /// Callback wired by [ToolExtensionEntryPoint.run] when the JSON-RPC peer + /// channel connects. + @internal + ExtensionNotificationSender? notificationSender; + + /// Callback wired by [ToolExtensionEntryPoint.run] when the JSON-RPC peer + /// channel connects to send requests to the host tool. + @internal + ExtensionRequestSender? requestSender; + + /// The JSON-RPC method namespace prefix owned by this service (for example, + /// `'device'` or `'build'`). + String get namespace; + + /// Initializes state needed by the service and returns a map of RPC method + /// handlers (without the [namespace] prefix). + Future> initialize(); + + /// Sends a fire-and-forget JSON-RPC 2.0 notification to the host tool. + /// Throws a [StateError] if called before the peer channel is connected. + void sendNotification(String method, Map params) { + final ExtensionNotificationSender? sender = notificationSender; + if (sender == null) { + throw StateError( + 'Cannot send notification "$method": service "$namespace" is not ' + 'attached to an active peer.', + ); + } + sender(method, params); + } + + /// Sends a structured `extension.log` notification to the host tool. + void log( + String message, { + ExtensionLogLevel level = .info, + DateTime? timestamp, + }) { + sendNotification(logNotificationMethod, { + 'level': level.name, + 'message': message, + 'timestamp': (timestamp ?? DateTime.now()).toUtc().toIso8601String(), + }); + } + + /// Emits an `extension.progress` notification to report progress for + /// long-running operations to the host tool. + void reportProgress(String message) { + sendNotification(progressNotificationMethod, { + 'message': message, + }); + } + + Future _sendRequest(String method, Map params) { + final ExtensionRequestSender? sender = requestSender; + if (sender == null) { + throw StateError( + 'Cannot send request "$method": service "$namespace" is not ' + 'attached to an active peer.', + ); + } + return sender(method, params); + } + + /// Queries the host tool over the bidirectional JSON-RPC `Peer` for the + /// persisted `flutter config` values owned by this extension (`bool` for + /// declared `FeatureFlag.settingName` keys, `String` for declared + /// `ConfigOption.name` and `ConfigAction.settingsKeys`), omitting unset + /// keys. + Future> getConfigurationValues() async { + final Object? response = await _sendRequest( + getConfigurationValuesRequestMethod, + const {}, + ); + if (response case final Map map) { + return map; + } + throw FormatException( + 'Expected Map from ' + '"$getConfigurationValuesRequestMethod", got ${response.runtimeType}.', + response, + ); + } + + /// Prompts the user to enter a line of text via the host tool's interactive + /// terminal over the bidirectional JSON-RPC peer. + /// + /// When [echo] is `false`, the host disables terminal character echoing + /// while reading the line (for pairing passphrases or PINs). Returns the + /// entered text (including `''` or [defaultValue]), or `null` if the user + /// cancels input (EOF). Throws a [ToolExtensionException] if the host tool + /// is running without an interactive terminal or with `--machine`. + Future promptForInput({ + required String message, + String? defaultValue, + bool echo = true, + }) async { + final Object? response = await _sendRequest( + promptForInputRequestMethod, + { + 'message': message, + 'defaultValue': ?defaultValue, + 'echo': echo, + }, + ); + if (response case {'value': final String? value}) { + return value; + } + throw FormatException( + 'Expected {"value": String?} from "$promptForInputRequestMethod", got ' + '${response.runtimeType}.', + response, + ); + } + + /// Prompts the user to select an option from [options] via the host tool's + /// interactive terminal over the bidirectional JSON-RPC peer. + /// + /// Returns the selected 0-based index in [options], or `null` if the user + /// cancels the prompt. Throws a [ToolExtensionException] if the host tool is + /// running without an interactive terminal or with `--machine`. + Future promptForOption({ + required String message, + required List options, + int? defaultChoiceIndex, + }) async { + final Object? response = await _sendRequest( + promptForOptionRequestMethod, + { + 'message': message, + 'options': options, + 'defaultChoiceIndex': ?defaultChoiceIndex, + }, + ); + if (response case {'selectedIndex': final int? selectedIndex}) { + return selectedIndex; + } + throw FormatException( + 'Expected {"selectedIndex": int?} from "$promptForOptionRequestMethod", ' + 'got ${response.runtimeType}.', + response, + ); + } + + /// Cleans up resources held by the service when the extension is shut down. + Future shutdown() async {} +} + +/// Bootstraps a tool extension isolate and binds registered +/// [ToolExtensionService]s to a JSON-RPC 2.0 peer over an `IsolateChannel`. +abstract final class ToolExtensionEntryPoint { + /// Initializes [services], connects the isolate handshake port from + /// [message], reports `ToolExtensionCapabilities` (`services` and optional + /// [supportedHostPlatforms]), and listens for JSON-RPC 2.0 requests inside + /// an `IOOverrides` and print-capturing `Zone`. + /// + /// [supportedHostPlatforms] defaults to `null` (all host platforms + /// supported); passing a non-null empty set throws an [ArgumentError]. When + /// [supportedHostPlatforms] excludes the current host platform, `run` skips + /// `service.initialize()` while still serving `extension.getCapabilities` so + /// the host tool can cache capabilities and shut down cleanly. + /// + /// Both [services] and [supportedHostPlatforms] must depend only on the + /// extension package snapshot (never branching on environment variables, + /// `PATH` or installed host toolchains, working directory, or project + /// contents) so cached handshake capabilities remain valid across invocations + /// and projects, and the extension's `main()` entrypoint and `*Service` + /// constructors must be side-effect-free, deferring any host-specific work to + /// service RPC handlers (`getDevices`, `runDiagnostics`, `build`, etc.). + static Future run( + Object? message, + List services, { + Set? supportedHostPlatforms, + }) async { + // Validates that `message` is a SendPort and `supportedHostPlatforms` is + // null or non-empty, initializes services when the current host is + // supported, connects the IsolateChannel, and listens inside + // IOOverrides.runZoned and a print-capturing Zone. + } +} +``` + +#### 4.2 Diagnostics (`flutter doctor`) + +To provide environment diagnostics via `flutter doctor`, extensions can register a `DiagnosticsService` that runs one or more validation checks for the target platform's SDK and host toolchain. Each extension providing a `DiagnosticsService` appears as a category in `flutter doctor`, and when `runDiagnostics()` returns multiple `ValidationResult` entries, the tool merges them by severity (`crash > missing > partial > notAvailable > success`). + +```dart +/// The service responsible for executing custom diagnostic checks that +/// can be reported via `flutter doctor`. +abstract base class DiagnosticsService extends ToolExtensionService { + /// The JSON-RPC namespace for diagnostics RPCs (`'diagnostics'`). + static const String serviceNamespace = 'diagnostics'; + + /// The JSON-RPC method namespace owned by this service. + @override + String get namespace => serviceNamespace; + + /// This is displayed in the CLI as the header for the check (for example, + /// `'Tizen toolchain'`). + String get title; + + /// An RPC handler the main tool invokes to retrieve and run all custom + /// diagnostic checks provided by this extension. + Future> runDiagnostics(); +} +``` + +When `flutter doctor` runs in a project with custom embedder extensions installed, each extension's diagnostic result (including passing `[✓]`, partial `[!]`, or failing `[✗]` status and any `ValidationMessage` error or hint lines) is displayed alongside the built-in categories: + +```text +$ flutter doctor +Doctor summary (to see all details, run flutter doctor -v): +[✓] Flutter (Channel main, 3.43.0-1.0.pre, on Linux, locale en_US.UTF-8) +[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.0) +[✓] Chrome - develop for the web +[✓] Linux toolchain - develop for Linux desktop +[✗] Tizen toolchain - develop for Tizen devices + ✗ Tizen Studio is not installed; download from https://developer.tizen.org/development/tizen-studio/download + ! Set the Tizen Studio installation path with "flutter config --tizen-sdk=" +[!] Embedded Linux toolchain - develop for eLinux devices (eLinux SDK 1.4.0) + ! clang++ cross-compiler for aarch64-linux-gnu not found on PATH +[✓] Connected device (2 available) +[✓] Network resources +``` + +#### 4.3 Configuration (`flutter config`) + +To support custom configuration options, extensions can provide an implementation of `ConfigService`, which allows extensions to add boolean feature flags (`--[no-]enable-`), key-value configuration options (`--