Authoring a tvOS Plugin
May 24, 2026 · View on GitHub
This guide explains the structure of a *_tvos federated plugin and how
to finish one by hand.
Start with the porter. Almost every package in
packages/was produced byflutter-tvos plugin portand then finished/verified by hand. See Porting an existing plugin for the full reference, and "How this repository was created" inREADME.mdfor the exact commands used here. Run that first; use this document to understand the generated layout and to complete the parts the porter intentionally leaves to a human (FFI/native-assets skeletons, tvOS-incompatible regions it disabled, cascades). Authoring a package fully by hand is the fallback, not the common path.
1. Decide the shape
Two shapes are possible:
| Shape | Name | When |
|---|---|---|
| Port | <upstream>_tvos | You're adding tvOS support to an existing pub.dev plugin (e.g. url_launcher). Federate via the upstream *_platform_interface package. |
| Exclusive | tvos_<feature> | You're exposing a tvOS-only API (Siri Remote, top-shelf extension, focus engine bridge). No platform interface to inherit. |
This guide focuses on ports — the common case. Exclusives skip the
implements: / platform_interface pieces.
2. Pick the upstream APIs
Before writing any code, read the upstream plugin's
*_platform_interface/lib/<name>_platform_interface.dart. List every
abstract method and decide for each:
- Supported — implement via a tvOS system API
- Unsupported — throw
UnsupportedErrorwith a reason
Document the decision in a table in your README.md. This is the single
most useful thing for downstream users.
Common tvOS "unsupported" reasons:
- No WebKit (affects
url_launcher,webview_flutter, anything opening a browser) - No haptics (affects
vibration, haptic feedback) - No camera / microphone (affects
camera,image_picker) - No Mail/Phone/SMS apps (affects
url_launcherschemes,share_plus) - No user-visible file system (affects
path_providerDownloads/External) - No in-process
fork()(affects process-spawning plugins)
3. Scaffold the package
cd plugins/packages
mkdir -p <name>_tvos/{lib,tvos/Classes}
cd <name>_tvos
cp ../path_provider_tvos/LICENSE .
Then create the six files described below. Using path_provider_tvos as
a template and renaming is the fastest path.
pubspec.yaml
name: <name>_tvos
description: tvOS implementation of the <name> plugin.
version: 0.1.0
homepage: https://github.com/fluttertv/plugins/tree/main/packages/<name>_tvos
repository: https://github.com/fluttertv/plugins
issue_tracker: https://github.com/fluttertv/plugins/issues
environment:
sdk: ">=3.0.0 <4.0.0"
flutter: ">=3.3.0"
flutter:
plugin:
implements: <name> # upstream name, e.g. "url_launcher"
platforms:
tvos:
pluginClass: <Name>TvosPlugin # Swift class, native registrar
dartPluginClass: <Name>Tvos # Dart class, Dart-side registrar
dependencies:
flutter:
sdk: flutter
<name>_platform_interface: ^X.Y.Z
dev_dependencies:
flutter_test:
sdk: flutter
The implements: key is what makes this a federated implementation —
Flutter's plugin resolver picks it when a tvOS build depends on the
upstream. The dartPluginClass makes the Dart-side registrar fire at
isolate startup so channel methods route correctly even before the first
widget build.
lib/<name>_tvos.dart
import 'package:flutter/services.dart';
import 'package:<name>_platform_interface/<name>_platform_interface.dart';
// IMPORTANT (ports): use the SAME channel name the upstream platform
// interface already uses (look at its MethodChannel*/Pigeon source —
// e.g. `plugins.flutter.io/path_provider`). Federation works because
// the upstream Dart code talks to that channel and your tvOS native
// side answers it. Inventing a new `plugins.fluttertv.dev/...` channel
// here would cause `MissingPluginException`. A brand-new
// `plugins.fluttertv.dev/<name>_tvos` channel is only for **exclusive**
// (`tvos_*`) plugins that have no upstream interface.
const String _channelName = 'plugins.flutter.io/<upstream-channel>';
class <Name>Tvos extends <Upstream>Platform {
static const MethodChannel _channel = MethodChannel(_channelName);
static void registerWith() {
<Upstream>Platform.instance = <Name>Tvos();
}
// Override each method from the platform interface here.
// For unsupported ones:
// throw UnsupportedError('<method> is not supported on tvOS');
}
Many modern plugins (shared_preferences, video_player, …) use Pigeon, not a hand-rolled
MethodChannel. For those, the porter copies the generated messages file verbatim and you do not pick a channel name at all — keep the generated channel.
Dart-3 class-modifier gotcha: if the platform interface uses base class,
your subclass must also carry a modifier (base, final, or sealed).
Example: base class SharedPreferencesAsyncTvos extends SharedPreferencesAsyncPlatform.
Without it, pub get fails with a type-system error.
tvos/Classes/<Name>TvosPlugin.swift
import Flutter
import UIKit // or Foundation-only, depending on the API
public class <Name>TvosPlugin: NSObject, FlutterPlugin {
// Ports: the upstream platform-interface channel (must match Dart).
private static let channelName = "plugins.flutter.io/<upstream-channel>"
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: channelName, binaryMessenger: registrar.messenger())
let instance = <Name>TvosPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "<methodA>": // ...
result(...)
default:
result(FlutterMethodNotImplemented)
}
}
}
Guidelines:
- Always match the channel name on both sides exactly. For a
port that is the upstream platform-interface channel (e.g.
plugins.flutter.io/path_provider), not a new one. Only exclusive (tvos_*) plugins useplugins.fluttertv.dev/<name>_tvos. - Never import WebKit, SafariServices, CoreTelephony, MessageUI, CoreLocation, AVCaptureDevice — not available on tvOS. Compile/link fails at the pod stage.
- Ported code keeps the upstream iOS sources and therefore does
use
#if os(tvOS)/#if !os(tvOS)(and#if TARGET_OS_TVin ObjC) guards — that is how the porter disables tvOS-incompatible regions while keeping the rest. Only brand-new, tvOS-only code written from scratch can assume the target without guards. - Use async completion for I/O —
UIApplication.open, file system writes, etc. Callresult(...)inside the callback.
tvos/<name>_tvos.podspec
Copy verbatim from path_provider_tvos/tvos/path_provider_tvos.podspec,
change s.name, s.summary, s.description. Do not change the
xcconfig block — it solves the "Flutter pod doesn't declare tvOS
support" problem by pointing at the app's pre-linked Flutter.framework.
Key invariants in the podspec:
s.platform = :tvos, '13.0'— matches the engine deployment target- No
s.dependency 'Flutter'— would fail pod install FRAMEWORK_SEARCH_PATHS = "${PODS_ROOT}/../Flutter"— whereflutter-tvos buildstages Flutter.frameworkOTHER_LDFLAGS = '-framework Flutter'— links against itTARGETED_DEVICE_FAMILY = '3'— tvOS device family
README.md and CHANGELOG.md
Copy and adapt path_provider_tvos's files. The README must have:
- Install snippet (
pubspec.yaml+dependencies) - Capability table (what works, what's unsupported, why)
- Requirements section (tvOS 13.0+,
flutter-tvosengine)
CHANGELOG.md starts with ## 0.1.0 and notes what's implemented/stubbed.
LICENSE
Copy from path_provider_tvos/LICENSE (BSD-3-Clause, FlutterTV Authors).
4. Register the plugin in this repo
Edit plugins/README.md, add the new row to the Ports table.
5. Test against a real app
There is no isolated unit test harness for plugins in this repo yet. Testing is app-driven:
# In tvos_demo/pubspec.yaml, add:
# <name>:
# <name>_tvos:
# path: ../plugins/packages/<name>_tvos
cd tvos_demo
flutter-tvos build tvos --simulator --debug
flutter-tvos run -d <simulator_id>
Then exercise every method from Dart and confirm you see method-channel
activity in the native logs. For the "unsupported" methods, confirm the
UnsupportedError reaches Dart.
The CLI discovers your plugin via flutter.plugin.platforms.tvos — if
that key is missing, the plugin will silently not register, and you'll
get MissingPluginException at runtime.
6. Commit and push
From plugins/:
git add packages/<name>_tvos README.md
git commit -m "Add <name>_tvos"
git push
Make sure your git identity is set to your personal profile, not a CI
bot. CI will run flutter pub get + flutter analyze on the new package.
Checklist (use this when porting)
- Package created under
plugins/packages/<name>_tvos/ -
pubspec.yamlhasimplements:,pluginClass:,dartPluginClass: - Dart class extends the upstream platform interface
- Dart class has
registerWith()static method - Every platform-interface method is implemented or throws
UnsupportedError - Swift class is
NSObject, FlutterPluginwithregister(with:)andhandle(_:result:) - Channel name matches on both sides — the upstream platform-interface channel for ports (
plugins.fluttertv.dev/<name>_tvosonly fortvos_*exclusives) - Podspec has
s.platform = :tvos, '13.0', no Flutter dependency, correct xcconfig - README has capability table and unsupported-reason documentation
-
plugins/README.mdPorts table updated - Tested end-to-end in
tvos_demo— method channel fires, results return, errors propagate
Common pitfalls
- Forgot
dartPluginClass— Dart-side registrar never runs,MissingPluginExceptionat first call. - Channel name mismatch — Dart says
plugins.fluttertv.dev/foo_tvos, Swift saysfoo_tvos/channel. Both sides must match exactly. - Missing
base/finalclass modifier — Dart 3 type system rejects it,pub getfails. s.dependency 'Flutter'— the Flutter pod has no tvOS variant, pod install dies.- Imported a framework that isn't on tvOS —
WebKit,SafariServices,MessageUI,CoreTelephony,AVKit'sAVPictureInPictureController. Discover these atpod installor link time, not compile time. - Forgot the upstream plugin in
dependencies— user has to add both<name>and<name>_tvosto their pubspec. Document this in the README install snippet. - Empty
GeneratedPluginRegistrant.swift— meansensureReadyForTvosTooling()didn't find your plugin. Check.flutter-plugins-dependenciesin the demo app for your plugin under theplugins.tvoskey.
Reference implementations
When unsure, compare against these in packages/:
- Hand-written native, no FFI →
path_provider_tvos(NSSearchPathForDirectoriesInDomains/NSTemporaryDirectory) - Ported native + tvOS-guarded incompatible regions →
flutter_secure_storage_tvos(Keychain kept;LAContext/biometrics guarded out) - Pigeon-based, federated via
dartPluginClass→shared_preferences_tvos,video_player_tvos - Audio-session option no-ops on tvOS →
audioplayers_tvos,flutter_tts_tvos