Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,48 @@

All notable changes to flutter-tvos will be documented here.

## [1.3.1] — 2026-06-16

Refreshes the pinned engine to **Flutter 3.44.2** (`c9a6c484`, Dart
`d684a576`) and fixes tvOS platform identity in AOT (release/profile) builds.

### Fixed
- **`Platform.operatingSystem` / `isIOS` / `isTvOS` are now correct in release.**
These getters carry `@pragma("vm:platform-const")`, which `gen_snapshot`
const-folds in AOT from the build's `--target-os`. Stock Flutter passes
`--target-os ios` for `TargetPlatform.ios` (which tvOS rides), so release
baked in `operatingSystem == "ios"` and `isTvOS == false` for all app and
plugin code — debug/JIT was unaffected. `TvosKernelSnapshot` now compiles AOT
with `targetOS: null` and against the patched `flutter_patched_sdk` from the
host engine artifact, so the getters resolve at runtime to `"tvos"` exactly as
debug already did. Verified on a physical Apple TV.
- **Profile builds no longer crash at startup** with `Type '_NetworkProfiling'
not found in library 'dart.io'`. Profile now compiles against the
**non-product** host SDK (`host_debug_unopt`) instead of the product
`host_release` one: the non-product engine looks up entry-point classes (e.g.
`dart:io` `_NetworkProfiling`) that the product SDK doesn't retain through AOT
tree-shaking. Mirrors stock Flutter's `flutter_patched_sdk` (profile) vs
`flutter_patched_sdk_product` (release) split. Release is unchanged.
- **On-device `--debug` (JIT + hot reload) works on tvOS 26.** tvOS 26 denies
flipping JIT code pages RW→RX via `mprotect` (even with a debugger attached),
and the iOS dual-mapping RX workaround needs Mach exception-port APIs
unavailable on tvOS, so the debug VM aborted at `StubCode::Init`
(`mprotect failed: 13`). The device-debug engine now maps JIT pages **RWX up
front** (`FLAG_write_protect_code = false`, tvOS device only) — no RW↔RX flip
to be denied — restoring hot reload on hardware. Verified on a physical Apple
TV 4K (A15, tvOS 26.5). Debug-only (W+X); AOT release/profile keep W^X; the
simulator is unaffected.

### Changed
- Bumped `bin/internal/flutter.version` to `c9a6c484` (Flutter 3.44.2) and
`bin/internal/engine.version` to `v1.0.0-flutter3.44.2`.
- Rebuilt all six engine artifact variants against Dart `d684a576`. Required: a
Flutter-version-only bump leaves debug/simulator builds working but breaks AOT
(`gen_snapshot: Invalid SDK hash`) because the old `gen_snapshot` cannot load
kernel compiled by the new Dart SDK.
- Bundled `flutter_tvos` plugin updated to 1.1.1 (remote-control configure
handshake retry).

## [1.3.0] — 2026-06-12

Minor release. Adds **Swift Package Manager support** for tvOS apps (the
Expand Down
2 changes: 1 addition & 1 deletion bin/internal/engine.version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v1.0.0-flutter3.44.1
v1.0.0-flutter3.44.2
2 changes: 1 addition & 1 deletion bin/internal/flutter.version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
924134a44c189315be2148659913dda1671cbe99
c9a6c484230f8b5e408ec57be1ef71dee1e77020
111 changes: 111 additions & 0 deletions lib/build_targets/application.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ import 'package:flutter_tools/src/base/io.dart';
import 'package:flutter_tools/src/base/logger.dart' show Status;
import 'package:flutter_tools/src/build_info.dart';
import 'package:flutter_tools/src/build_system/build_system.dart';
import 'package:flutter_tools/src/build_system/exceptions.dart';
import 'package:flutter_tools/src/build_system/targets/common.dart';
import 'package:flutter_tools/src/build_system/targets/localizations.dart';
import 'package:flutter_tools/src/compile.dart';
import 'package:flutter_tools/src/dart/package_map.dart';
import 'package:flutter_tools/src/globals.dart' as globals;
import 'package:flutter_tools/src/project.dart';
import 'package:meta/meta.dart';
import 'package:package_config/package_config.dart';

import '../tvos_artifacts.dart';
import '../tvos_build_info.dart';
Expand Down Expand Up @@ -81,6 +85,113 @@ class TvosKernelSnapshot extends KernelSnapshot {
GenerateLocalizationsTarget(),
TvosDartPluginRegistrantTarget(),
];

/// Mirrors [KernelSnapshot.build] but forces `targetOS: null` so the
/// frontend-server does **not** const-fold `Platform.operatingSystem` to
/// `"ios"` in AOT (profile/release) builds.
///
/// `dart:io`'s `Platform.operatingSystem` / `isIOS` / `isTvOS` carry
/// `@pragma("vm:platform-const")`. When the kernel compiler is handed a
/// target OS (`--target-os`, which stock Flutter sets to `ios` for
/// `TargetPlatform.ios`), gen_snapshot const-folds those getters at compile
/// time from that name — baking in `operatingSystem == "ios"` and
/// `isTvOS == false`. The Dart SDK has no `tvos` target-OS, so the only way
/// to keep tvOS identity correct in release is to not fold at all: with
/// `targetOS == null` the getters stay live and resolve at runtime against
/// the engine's patched native VM (`kHostOperatingSystemName == "tvos"`),
/// exactly as JIT/debug already does. Debug and release then report
/// identical platform identity for *all* code and plugins, not just
/// `package:flutter_tvos`.
///
/// The companion half is in [TvosArtifacts]: the AOT kernel is compiled
/// against our **patched** `flutter_patched_sdk`, so the (now un-folded)
/// `isIOS` initializer is `operatingSystem == "ios" || == "tvos"` and the
/// `isTvOS` getter exists.
///
/// Kept in sync with upstream `KernelSnapshot.build`; re-mirror any change
/// on a Flutter upgrade. tvOS-specific simplifications: `targetPlatform` is
/// always `ios` (tvOS rides the iOS pipeline), so `forceLinkPlatform` is
/// false and `targetModel` is `flutter`; app flavors are not wired for tvOS,
/// so the flavor-define step is omitted.
@override
Future<void> build(Environment environment) async {
final compiler = KernelCompiler(
fileSystem: environment.fileSystem,
logger: environment.logger,
processManager: environment.processManager,
artifacts: environment.artifacts,
fileSystemRoots: <String>[],
);
final String? buildModeEnvironment = environment.defines[kBuildMode];
if (buildModeEnvironment == null) {
throw MissingDefineException(kBuildMode, 'kernel_snapshot');
}
final String? targetPlatformEnvironment = environment.defines[kTargetPlatform];
if (targetPlatformEnvironment == null) {
throw MissingDefineException(kTargetPlatform, 'kernel_snapshot');
}
final buildMode = BuildMode.fromCliName(buildModeEnvironment);
final String targetFile = environment.defines[kTargetFile] ??
environment.fileSystem.path.join('lib', 'main.dart');
final File packagesFile = findPackageConfigFileOrDefault(environment.projectDir);
final String targetFileAbsolute = environment.fileSystem.file(targetFile).absolute.path;
// everything besides 'false' is considered to be enabled.
final trackWidgetCreation = environment.defines[kTrackWidgetCreation] != 'false';
final TargetPlatform targetPlatform = getTargetPlatformForName(targetPlatformEnvironment);

// This configuration is all optional.
final String? frontendServerStarterPath = environment.defines[kFrontendServerStarterPath];
final List<String> extraFrontEndOptions =
decodeCommaSeparated(environment.defines, kExtraFrontEndOptions);
final List<String>? fileSystemRoots = environment.defines[kFileSystemRoots]?.split(',');
final String? fileSystemScheme = environment.defines[kFileSystemScheme];

// tvOS always rides the iOS pipeline (TargetPlatform.ios): never Fuchsia,
// never a desktop embedder. So the target model stays the default
// `TargetModel.flutter` and we don't need to force-link the platform.
const forceLinkPlatform = false;

final PackageConfig packageConfig = await loadPackageConfigWithLogging(
packagesFile,
logger: environment.logger,
);

final String dillPath = environment.buildDir.childFile(KernelSnapshot.dillName).path;
final List<String> dartDefines = decodeDartDefines(environment.defines, kDartDefines);

final CompilerOutput? output = await compiler.compile(
sdkRoot: environment.artifacts.getArtifactPath(
Artifact.flutterPatchedSdkPath,
platform: targetPlatform,
mode: buildMode,
),
aot: buildMode.isPrecompiled,
buildMode: buildMode,
trackWidgetCreation: trackWidgetCreation && buildMode != BuildMode.release,
outputFilePath: dillPath,
initializeFromDill: buildMode.isPrecompiled ? null : dillPath,
packagesPath: packagesFile.path,
linkPlatformKernelIn: forceLinkPlatform || buildMode.isPrecompiled,
mainPath: targetFileAbsolute,
depFilePath: environment.buildDir.childFile(KernelSnapshot.depfile).path,
frontendServerStarterPath: frontendServerStarterPath,
extraFrontEndOptions: extraFrontEndOptions,
fileSystemRoots: fileSystemRoots,
fileSystemScheme: fileSystemScheme,
dartDefines: dartDefines,
packageConfig: packageConfig,
buildDir: environment.buildDir,
// The tvOS fix. Stock Flutter passes `ios` here for TargetPlatform.ios,
// which const-folds platform identity to iOS in AOT. Passing null keeps
// the platform-const getters live so they resolve at runtime to "tvos".
// ignore: avoid_redundant_argument_values
targetOS: null,
checkDartPluginRegistry: environment.generateDartPluginRegistry,
);
if (output == null || output.errorCount != 0) {
throw Exception();
}
}
}

/// [AotElfRelease] subclass that uses [TvosKernelSnapshot] instead of the
Expand Down
50 changes: 50 additions & 0 deletions lib/tvos_artifacts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,31 @@ class TvosArtifacts extends CachedArtifacts {
return _fileSystem.path.join(engineDir, 'Flutter.xcframework');
}
}

// For AOT (profile/release) builds, compile the app kernel against OUR
// patched `flutter_patched_sdk` (shipped inside the host engine artifact)
// rather than the stock Flutter checkout's. The patched dart:io
// `platform.dart` defines `isIOS = operatingSystem == "ios" || == "tvos"`
// and adds the `isTvOS` getter, so the un-folded platform-const getters
// evaluate correctly at runtime on tvOS. This is the companion to
// `TvosKernelSnapshot.build()`, which passes `targetOS: null` so those
// getters are not const-folded to "ios" at compile time.
//
// Debug (JIT) deliberately keeps the stock SDK: platform identity there is
// resolved by the device engine's own (patched) core libraries at runtime,
// so the compile SDK is irrelevant and we avoid disturbing the proven
// debug path. We only need our patched SDK where gen_snapshot bakes the
// SDK code into the app snapshot — i.e. precompiled builds.
if ((mode?.isPrecompiled ?? false) &&
(artifact == Artifact.flutterPatchedSdkPath ||
artifact == Artifact.platformKernelDill)) {
final String patchedSdkDir = _hostPatchedSdkDirectory(mode!);
if (artifact == Artifact.platformKernelDill) {
return _fileSystem.path.join(patchedSdkDir, 'platform_strong.dill');
}
return patchedSdkDir;
}

return super.getArtifactPath(
artifact,
platform: platform,
Expand All @@ -60,6 +85,31 @@ class TvosArtifacts extends CachedArtifacts {
);
}

/// Path to the patched `flutter_patched_sdk` inside the host engine
/// artifact for [mode].
///
/// Release uses the **product** SDK (`host_release`); profile uses the
/// **non-product** SDK (`host_debug_unopt`). This mirrors stock Flutter's
/// `flutter_patched_sdk` (debug/profile) vs `flutter_patched_sdk_product`
/// (release) split: the non-product SDK marks entry-point classes that the
/// profile/JIT engine looks up natively — e.g. `dart:io`'s
/// `_NetworkProfiling` — so gen_snapshot keeps them through AOT
/// tree-shaking. Compiling a profile build against the product SDK drops
/// those classes and the engine aborts at startup with
/// `Type '_NetworkProfiling' not found in library 'dart.io'`.
String _hostPatchedSdkDirectory(BuildMode mode) {
final dirName = mode == BuildMode.release ? 'host_release' : 'host_debug_unopt';
// Handle the nested directory that zip extraction can produce
// (`<root>/<dir>/<dir>/flutter_patched_sdk`).
final Directory nested = _fileSystem.directory(
_fileSystem.path.join(_tvosArtifactRoot, dirName, dirName, 'flutter_patched_sdk'),
);
if (nested.existsSync()) {
return nested.path;
}
return _fileSystem.path.join(_tvosArtifactRoot, dirName, 'flutter_patched_sdk');
}

String get _tvosArtifactRoot {
return tvosArtifactDirectory(globals.fs).path;
}
Expand Down
13 changes: 13 additions & 0 deletions packages/flutter_tvos/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
## 1.1.1

- Fixed the remote-control `configure` handshake racing native plugin
registration at startup. `TvRemoteController.init()` now retries the
`configure` call until the native `FlutterTvRemotePlugin` acknowledges it,
so the touchpad reliably starts forwarding swipe/touch events instead of
intermittently appearing dead when the first push landed before the native
handler was registered. The retry now only swallows the expected
`MissingPluginException` / `PlatformException` (any other error — e.g. a
config serialization bug — propagates instead of being silently retried),
and if the handshake is never acknowledged within the retry budget it
surfaces via `FlutterError.reportError` rather than giving up silently.

## 1.1.0

- Added Swift Package Manager support. `flutter_tvos` now ships a
Expand Down
10 changes: 9 additions & 1 deletion packages/flutter_tvos/example/lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:io' show Platform;

import 'package:flutter/material.dart';
import 'package:flutter_tvos/flutter_tvos.dart';

Expand Down Expand Up @@ -129,7 +131,13 @@ class PlatformInfoScreen extends StatelessWidget {
return ListView(
padding: const EdgeInsets.all(16),
children: [
_InfoTile(label: 'Is tvOS', value: '${TvOSInfo.isTvOS}'),
// Platform identity via the Dart VM (not FFI). These must read "tvos"
// / true in BOTH debug and release — they exercise the AOT
// platform-const path that the engine + CLI fix makes consistent.
_InfoTile(label: 'Platform.operatingSystem', value: Platform.operatingSystem),
_InfoTile(label: 'Platform.isIOS', value: '${Platform.isIOS}'),
_InfoTile(label: 'FlutterTvosPlatform.isTvos', value: '${FlutterTvosPlatform.isTvos}'),
_InfoTile(label: 'Is tvOS (FFI)', value: '${TvOSInfo.isTvOS}'),
_InfoTile(label: 'tvOS Version', value: TvOSInfo.tvOSVersion),
_InfoTile(label: 'Device Model', value: TvOSInfo.deviceModel),
_InfoTile(label: 'Machine ID', value: TvOSInfo.machineId),
Expand Down
84 changes: 76 additions & 8 deletions packages/flutter_tvos/lib/src/rcu/tv_remote_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import 'dart:async' show unawaited;

import 'package:flutter/foundation.dart'
show ErrorDescription, FlutterError, FlutterErrorDetails, visibleForTesting;
import 'package:flutter/services.dart'
show MissingPluginException, PlatformException;

import '../platform_extension.dart' show FlutterTvosPlatform;
import 'swipe_detector.dart';
Expand Down Expand Up @@ -187,6 +189,10 @@ class TvRemoteController {
final _swipeListeners = <SwipeListener>[];
bool _initialized = false;

/// Bumped on every [_pushConfig] (and [debugReset]) so an in-flight retry
/// loop from a superseded config can detect it is stale and bail out.
int _configPushGeneration = 0;

/// Wire up channel handlers. Idempotent — subsequent calls are no-ops.
/// Does nothing if not running on tvOS.
void init() {
Expand All @@ -210,6 +216,8 @@ class TvRemoteController {
_cachedSwipe = null;
_config = const TvRemoteConfig();
_initialized = false;
// Invalidate any in-flight retry loop from a previous test.
_configPushGeneration++;
}

void _attachChannelHandlers() {
Expand All @@ -222,14 +230,74 @@ class TvRemoteController {
}

void _pushConfig() {
unawaited(TvRemoteChannels.button
.invokeMethod<void>(TvRemoteProtocol.methodConfigure, _config.toMap())
.catchError((Object error, StackTrace stack) {
// Before native is reachable (e.g. iOS / Android / test binding
// without a plugin), the invocation fails. Silently ignore — the
// native side holds default values that match [TvRemoteConfig]
// defaults, so apps that never touch `config` behave identically.
}));
final int generation = ++_configPushGeneration;
unawaited(_pushConfigWithRetry(generation));
}

/// Ships the current [_config] to the native plugin, retrying until it is
/// acknowledged or [generation] is superseded.
///
/// The native side only starts forwarding touch events once it has received
/// a `configure` call (it sets `frameworkReadyForTouches = YES` then). At
/// app startup there is a race: [init] may push the config before the native
/// `FlutterTvRemotePlugin` has registered its method-call handler, so the
/// first invocation throws `MissingPluginException` and the handshake is
/// lost — the touchpad then appears dead even though everything compiled.
/// Retrying closes that window. Once native is reachable the call succeeds
/// on the next attempt; on iOS / Android / a test binding without the plugin
/// it simply retries harmlessly until the generation is bumped (config
/// reassigned, or [debugReset]) or the attempt budget is exhausted — the
/// native side holds defaults that match [TvRemoteConfig], so apps that
/// never touch `config` behave identically.
Future<void> _pushConfigWithRetry(int generation) async {
const Duration retryDelay = Duration(milliseconds: 100);
const int maxAttempts = 50;
Object? lastError;
StackTrace? lastStack;
for (int attempt = 0; attempt < maxAttempts; attempt++) {
if (generation != _configPushGeneration || !_initialized) {
return;
}
try {
await TvRemoteChannels.button.invokeMethod<void>(
TvRemoteProtocol.methodConfigure,
_config.toMap(),
);
return;
} on MissingPluginException catch (error, stack) {
// The handshake race this loop exists for: native
// `FlutterTvRemotePlugin` hasn't registered its handler yet. Retry.
lastError = error;
lastStack = stack;
await Future<void>.delayed(retryDelay);
} on PlatformException catch (error, stack) {
// Native is reachable but rejected the call (e.g. handler registered
// mid-startup). Retry in case it is transient. Any other error type
// (e.g. a serialization bug in _config.toMap()) is deliberately NOT
// caught — it propagates as an async error rather than being silently
// retried 50× and dropped indistinguishably from the race.
lastError = error;
lastStack = stack;
await Future<void>.delayed(retryDelay);
}
}
// Budget exhausted and the push is still current — the touchpad will run on
// native default tuning and never receive our overrides. Surface it instead
// of giving up silently (the rest of this class reports via reportError).
if (generation == _configPushGeneration && _initialized) {
FlutterError.reportError(FlutterErrorDetails(
exception: lastError ??
StateError('TV remote configure handshake was never acknowledged'),
stack: lastStack,
library: 'flutter_tvos',
context: ErrorDescription(
'while configuring the TV remote touchpad: the native `configure` '
'handshake was not acknowledged after $maxAttempts attempts '
'${retryDelay.inMilliseconds} ms apart. The touchpad will use native '
'default tuning; custom TvRemoteConfig values were not applied.',
),
));
}
}

/// Register a listener that receives every raw touchpad event.
Expand Down
Loading
Loading