From 245164c26d8a3352d45e02208649c25ff6270b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Ali=20Ustao=C4=9Flu?= Date: Sun, 14 Jun 2026 10:20:05 +0200 Subject: [PATCH 1/8] chore(release): upgrade engine to Flutter 3.44.2 (1.3.1) Bump flutter.version to c9a6c484 (Flutter 3.44.2) and engine.version to v1.0.0-flutter3.44.2. Engine artifacts rebuilt against Dart d684a576. A Flutter-version-only bump keeps simulator/debug working but breaks AOT with 'gen_snapshot: Invalid SDK hash' (old gen_snapshot cannot load kernel compiled by the new Dart SDK), so the engine rebuild is required for device/release builds. --- CHANGELOG.md | 15 +++++++++++++++ bin/internal/engine.version | 2 +- bin/internal/flutter.version | 2 +- pubspec.yaml | 2 +- 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 068a8b0..d7e7d24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to flutter-tvos will be documented here. +## [1.3.1] — 2026-06-14 + +Patch release. Refreshes the pinned engine to **Flutter 3.44.2** +(`c9a6c484`, Dart `d684a576`). No CLI behaviour change — Flutter 3.44.2 is a +hotfix whose only tvOS-relevant content is the Dart SDK roll plus build-tool +fixes inherited from the upstream `flutter_tools`. + +### 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 tvOS engine artifact variants against Dart `d684a576`. This + is 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. + ## [1.3.0] — 2026-06-12 Minor release. Adds **Swift Package Manager support** for tvOS apps (the diff --git a/bin/internal/engine.version b/bin/internal/engine.version index c4a1549..ef3fb1b 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -v1.0.0-flutter3.44.1 +v1.0.0-flutter3.44.2 diff --git a/bin/internal/flutter.version b/bin/internal/flutter.version index 4ee3b96..fd4cf32 100644 --- a/bin/internal/flutter.version +++ b/bin/internal/flutter.version @@ -1 +1 @@ -924134a44c189315be2148659913dda1671cbe99 +c9a6c484230f8b5e408ec57be1ef71dee1e77020 diff --git a/pubspec.yaml b/pubspec.yaml index 61296c7..2b96e37 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_tvos description: Flutter CLI tool for building and running Flutter apps on Apple TV (tvOS). A custom embedder wrapping Flutter SDK with tvOS-specific build targets, device management, and plugin support. -version: 1.3.0 +version: 1.3.1 homepage: https://fluttertv.dev repository: https://github.com/fluttertv/flutter-tvos publish_to: "none" From f9d655adabf47ef1d685ecdc55b88e9963a16777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Ali=20Ustao=C4=9Flu?= Date: Tue, 16 Jun 2026 07:43:50 +0200 Subject: [PATCH 2/8] fix(platform): make tvOS platform identity correct in AOT/release builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform.operatingSystem / isIOS / isTvOS 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 — correct in JIT/debug, dead on device in release. Keep debug and release on the same runtime-resolution path: - TvosKernelSnapshot.build() mirrors upstream KernelSnapshot.build() but passes targetOS: null, so the platform-const getters are not folded and resolve at runtime to "tvos" (the engine's patched kHostOperatingSystemName). - TvosArtifacts overrides flutterPatchedSdkPath / platformKernelDill for AOT only to the patched flutter_patched_sdk in the host engine artifact, so the un-folded isIOS initializer is operatingSystem == "ios" || == "tvos" and the isTvOS getter exists. Debug keeps the stock SDK (runtime resolution via the device engine makes the compile SDK irrelevant there). Verified on a physical Apple TV in release: operatingSystem == "tvos", isIOS == true, isTvOS == true, defaultTargetPlatform == iOS. Plugin isolation is unaffected — discovery is keyed on the pubspec tvos: key, not Platform.isIOS. Adds unit tests asserting the AOT frontend_server command omits --target-os and that the artifact override applies for AOT but falls through to stock for debug. The example info screen now surfaces the platform-identity values. --- lib/build_targets/application.dart | 111 ++++++++++++++ lib/tvos_artifacts.dart | 41 ++++++ packages/flutter_tvos/example/lib/main.dart | 10 +- test/general/tvos_artifacts_test.dart | 97 ++++++++++++ test/general/tvos_kernel_snapshot_test.dart | 155 ++++++++++++++++++++ 5 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 test/general/tvos_artifacts_test.dart create mode 100644 test/general/tvos_kernel_snapshot_test.dart diff --git a/lib/build_targets/application.dart b/lib/build_targets/application.dart index ec727e0..ae2ba1e 100644 --- a/lib/build_targets/application.dart +++ b/lib/build_targets/application.dart @@ -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'; @@ -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 build(Environment environment) async { + final compiler = KernelCompiler( + fileSystem: environment.fileSystem, + logger: environment.logger, + processManager: environment.processManager, + artifacts: environment.artifacts, + fileSystemRoots: [], + ); + 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 extraFrontEndOptions = + decodeCommaSeparated(environment.defines, kExtraFrontEndOptions); + final List? 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 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 diff --git a/lib/tvos_artifacts.dart b/lib/tvos_artifacts.dart index 16b2e3a..a29cd7c 100644 --- a/lib/tvos_artifacts.dart +++ b/lib/tvos_artifacts.dart @@ -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, @@ -60,6 +85,22 @@ class TvosArtifacts extends CachedArtifacts { ); } + /// Path to the patched `flutter_patched_sdk` inside the host engine + /// artifact for [mode]. Profile and release both use `host_release`; + /// `host_debug_unopt` is only consulted for non-precompiled callers. + String _hostPatchedSdkDirectory(BuildMode mode) { + final dirName = mode == BuildMode.debug ? 'host_debug_unopt' : 'host_release'; + // Handle the nested directory that zip extraction can produce + // (`///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; } diff --git a/packages/flutter_tvos/example/lib/main.dart b/packages/flutter_tvos/example/lib/main.dart index 0bc9383..5adf9ac 100644 --- a/packages/flutter_tvos/example/lib/main.dart +++ b/packages/flutter_tvos/example/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:io' show Platform; + import 'package:flutter/material.dart'; import 'package:flutter_tvos/flutter_tvos.dart'; @@ -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), diff --git a/test/general/tvos_artifacts_test.dart b/test/general/tvos_artifacts_test.dart new file mode 100644 index 0000000..b749386 --- /dev/null +++ b/test/general/tvos_artifacts_test.dart @@ -0,0 +1,97 @@ +// Copyright 2026 The FlutterTV Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:file/memory.dart'; +import 'package:flutter_tools/src/artifacts.dart'; +import 'package:flutter_tools/src/base/platform.dart'; +import 'package:flutter_tools/src/build_info.dart'; +import 'package:flutter_tools/src/cache.dart'; +import 'package:flutter_tvos/tvos_artifacts.dart'; + +import '../src/common.dart'; +import '../src/context.dart'; +import '../src/fakes.dart'; + +void main() { + late MemoryFileSystem fileSystem; + late FakeProcessManager processManager; + late Cache cache; + late TvosArtifacts artifacts; + + setUp(() { + fileSystem = MemoryFileSystem.test(); + processManager = FakeProcessManager.any(); + // tvosArtifactDirectory resolves as `/../engine_artifacts`. + Cache.flutterRoot = '/flutter'; + cache = Cache.test(fileSystem: fileSystem, processManager: processManager); + artifacts = TvosArtifacts( + fileSystem: fileSystem, + cache: cache, + platform: FakePlatform(operatingSystem: 'macos'), + operatingSystemUtils: FakeOperatingSystemUtils(), + ); + + // Lay down the patched host SDKs the override points at. + fileSystem + .directory('/engine_artifacts/host_release/flutter_patched_sdk') + .createSync(recursive: true); + fileSystem + .directory('/engine_artifacts/host_debug_unopt/flutter_patched_sdk') + .createSync(recursive: true); + }); + + group('TvosArtifacts patched-SDK override (AOT platform identity)', () { + test('release flutterPatchedSdkPath resolves to host_release patched SDK', () { + final String path = artifacts.getArtifactPath( + Artifact.flutterPatchedSdkPath, + mode: BuildMode.release, + ); + expect(path, '/engine_artifacts/host_release/flutter_patched_sdk'); + }); + + test('profile platformKernelDill resolves to host_release platform_strong.dill', () { + final String path = artifacts.getArtifactPath( + Artifact.platformKernelDill, + mode: BuildMode.profile, + ); + expect( + path, + '/engine_artifacts/host_release/flutter_patched_sdk/platform_strong.dill', + ); + }); + + test('debug flutterPatchedSdkPath falls through to stock resolution', () { + // Debug resolves platform identity at runtime via the device engine, so + // the override is intentionally NOT applied — the path must come from + // the stock CachedArtifacts logic, not our engine_artifacts host SDK. + final String path = artifacts.getArtifactPath( + Artifact.flutterPatchedSdkPath, + mode: BuildMode.debug, + ); + expect(path, isNot(contains('engine_artifacts'))); + }); + + test('debug platformKernelDill falls through to stock resolution', () { + final String path = artifacts.getArtifactPath( + Artifact.platformKernelDill, + mode: BuildMode.debug, + ); + expect(path, isNot(contains('engine_artifacts'))); + }); + + test('handles the nested directory layout from zip extraction', () { + fileSystem + .directory('/engine_artifacts/host_release/host_release/flutter_patched_sdk') + .createSync(recursive: true); + final String path = artifacts.getArtifactPath( + Artifact.flutterPatchedSdkPath, + mode: BuildMode.release, + ); + expect( + path, + '/engine_artifacts/host_release/host_release/flutter_patched_sdk', + ); + }); + }); +} diff --git a/test/general/tvos_kernel_snapshot_test.dart b/test/general/tvos_kernel_snapshot_test.dart new file mode 100644 index 0000000..8749b70 --- /dev/null +++ b/test/general/tvos_kernel_snapshot_test.dart @@ -0,0 +1,155 @@ +// Copyright 2026 The FlutterTV Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:file/memory.dart'; +import 'package:flutter_tools/src/artifacts.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/build_info.dart'; +import 'package:flutter_tools/src/build_system/build_system.dart'; +import 'package:flutter_tools/src/compile.dart'; +import 'package:flutter_tvos/build_targets/application.dart'; + +import '../src/common.dart'; +import '../src/fake_process_manager.dart'; + +const String kBoundaryKey = '4d2d9609-c662-4571-afde-31410f96caa6'; + +void main() { + late FakeProcessManager processManager; + late Environment environment; + late Artifacts artifacts; + late FileSystem fileSystem; + late Logger logger; + + setUp(() { + processManager = FakeProcessManager.empty(); + logger = BufferLogger.test(); + // Stock test artifacts: this isolates the build() targetOS behaviour from + // TvosArtifacts' patched-SDK override (tested separately). The sdk-root + // below therefore resolves to the stock test path. + artifacts = Artifacts.test(); + fileSystem = MemoryFileSystem.test(); + fileSystem.file('.dart_tool/package_config.json') + ..createSync(recursive: true) + ..writeAsStringSync('{"configVersion": 2, "packages":[]}'); + }); + + Environment buildEnv(BuildMode mode) { + final env = Environment.test( + fileSystem.currentDirectory, + defines: { + kBuildMode: mode.cliName, + // tvOS rides the iOS pipeline. + kTargetPlatform: getNameForTargetPlatform(TargetPlatform.ios), + }, + inputs: {}, + artifacts: artifacts, + processManager: processManager, + fileSystem: fileSystem, + logger: logger, + ); + env.buildDir.createSync(recursive: true); + return env; + } + + group('TvosKernelSnapshot.build (AOT platform identity)', () { + test('does NOT pass --target-os for a profile (AOT) build', () async { + environment = buildEnv(BuildMode.profile); + final String build = environment.buildDir.path; + final String sdkPath = artifacts.getArtifactPath( + Artifact.flutterPatchedSdkPath, + platform: TargetPlatform.ios, + mode: BuildMode.profile, + ); + + List? captured; + processManager.addCommands([ + FakeCommand( + command: [ + artifacts.getArtifactPath(Artifact.engineDartAotRuntime), + artifacts.getArtifactPath(Artifact.frontendServerSnapshotForEngineDartSdk), + '--sdk-root', + '$sdkPath/', + '--target=flutter', + '--no-print-incremental-dependencies', + ...buildModeOptions(BuildMode.profile, []), + '--track-widget-creation', + '--aot', + '--tfa', + // NOTE: the upstream KernelSnapshot emits '--target-os', 'ios' + // right here. Its deliberate absence is the platform-identity fix. + '--packages', + '/.dart_tool/package_config.json', + '--output-dill', + '$build/app.dill', + '--depfile', + '$build/kernel_snapshot_program.d', + '--verbosity=error', + 'file:///lib/main.dart', + ], + onRun: (List command) => captured = command, + stdout: 'result $kBoundaryKey\n$kBoundaryKey\n$kBoundaryKey $build/app.dill 0\n', + ), + ]); + + await const TvosKernelSnapshot().build(environment); + + // Primary guard: the recorded frontend-server invocation must not carry + // a target OS, or gen_snapshot would const-fold Platform.operatingSystem + // to "ios" and tvOS apps would mis-identify in release. + expect(captured, isNotNull); + expect(captured, isNot(contains('--target-os'))); + expect(processManager, hasNoRemainingExpectations); + }); + + test('still produces a valid kernel command for release (AOT)', () async { + environment = buildEnv(BuildMode.release); + final String build = environment.buildDir.path; + final String sdkPath = artifacts.getArtifactPath( + Artifact.flutterPatchedSdkPath, + platform: TargetPlatform.ios, + mode: BuildMode.release, + ); + + processManager.addCommands([ + FakeCommand( + command: [ + artifacts.getArtifactPath(Artifact.engineDartAotRuntime), + artifacts.getArtifactPath(Artifact.frontendServerSnapshotForEngineDartSdk), + '--sdk-root', + '$sdkPath/', + '--target=flutter', + '--no-print-incremental-dependencies', + ...buildModeOptions(BuildMode.release, []), + // release does not track widget creation + '--aot', + '--tfa', + '--packages', + '/.dart_tool/package_config.json', + '--output-dill', + '$build/app.dill', + '--depfile', + '$build/kernel_snapshot_program.d', + '--verbosity=error', + 'file:///lib/main.dart', + ], + stdout: 'result $kBoundaryKey\n$kBoundaryKey\n$kBoundaryKey $build/app.dill 0\n', + ), + ]); + + await const TvosKernelSnapshot().build(environment); + expect(processManager, hasNoRemainingExpectations); + }); + + test('throws MissingDefineException when build mode is absent', () async { + environment = buildEnv(BuildMode.profile); + environment.defines.remove(kBuildMode); + await expectLater( + () => const TvosKernelSnapshot().build(environment), + throwsException, + ); + }); + }); +} From 409d812816aae32b70ee6e8a10ab36a5edebc9c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Ali=20Ustao=C4=9Flu?= Date: Tue, 16 Jun 2026 07:43:57 +0200 Subject: [PATCH 3/8] fix(rcu): retry the configure handshake until the native plugin is ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At app startup the native FlutterTvRemotePlugin may not have registered its button-channel handler yet when init() pushes the initial config. The one-shot invocation then throws MissingPluginException and the handshake is lost — native never sets frameworkReadyForTouches and the touchpad appears dead even though everything compiled. Retry on a short interval until the call is acknowledged or a newer config supersedes it. Guarded by a generation counter so a superseded or reset push bails out, and never runs on iOS/Android (init() is isTvos-gated). --- packages/flutter_tvos/CHANGELOG.md | 9 ++++ .../lib/src/rcu/tv_remote_controller.dart | 50 ++++++++++++++++--- packages/flutter_tvos/pubspec.yaml | 2 +- .../test/rcu/tv_remote_controller_test.dart | 26 ++++++++++ 4 files changed, 78 insertions(+), 9 deletions(-) diff --git a/packages/flutter_tvos/CHANGELOG.md b/packages/flutter_tvos/CHANGELOG.md index 36df793..2805856 100644 --- a/packages/flutter_tvos/CHANGELOG.md +++ b/packages/flutter_tvos/CHANGELOG.md @@ -1,3 +1,12 @@ +## 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. + ## 1.1.0 - Added Swift Package Manager support. `flutter_tvos` now ships a diff --git a/packages/flutter_tvos/lib/src/rcu/tv_remote_controller.dart b/packages/flutter_tvos/lib/src/rcu/tv_remote_controller.dart index 4517ef4..3632bfa 100644 --- a/packages/flutter_tvos/lib/src/rcu/tv_remote_controller.dart +++ b/packages/flutter_tvos/lib/src/rcu/tv_remote_controller.dart @@ -187,6 +187,10 @@ class TvRemoteController { final _swipeListeners = []; 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() { @@ -210,6 +214,8 @@ class TvRemoteController { _cachedSwipe = null; _config = const TvRemoteConfig(); _initialized = false; + // Invalidate any in-flight retry loop from a previous test. + _configPushGeneration++; } void _attachChannelHandlers() { @@ -222,14 +228,42 @@ class TvRemoteController { } void _pushConfig() { - unawaited(TvRemoteChannels.button - .invokeMethod(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 _pushConfigWithRetry(int generation) async { + const Duration retryDelay = Duration(milliseconds: 100); + const int maxAttempts = 50; + for (int attempt = 0; attempt < maxAttempts; attempt++) { + if (generation != _configPushGeneration || !_initialized) { + return; + } + try { + await TvRemoteChannels.button.invokeMethod( + TvRemoteProtocol.methodConfigure, + _config.toMap(), + ); + return; + } catch (_) { + await Future.delayed(retryDelay); + } + } } /// Register a listener that receives every raw touchpad event. diff --git a/packages/flutter_tvos/pubspec.yaml b/packages/flutter_tvos/pubspec.yaml index cb6b2eb..3cd3f41 100644 --- a/packages/flutter_tvos/pubspec.yaml +++ b/packages/flutter_tvos/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_tvos description: Platform detection and utilities for Flutter apps running on Apple TV (tvOS). Provides runtime checks for tvOS, device info, and capability queries. -version: 1.1.0 +version: 1.1.1 homepage: https://fluttertv.dev repository: https://github.com/fluttertv/flutter-tvos issue_tracker: https://github.com/fluttertv/flutter-tvos/issues diff --git a/packages/flutter_tvos/test/rcu/tv_remote_controller_test.dart b/packages/flutter_tvos/test/rcu/tv_remote_controller_test.dart index 87e7a9a..e4f0373 100644 --- a/packages/flutter_tvos/test/rcu/tv_remote_controller_test.dart +++ b/packages/flutter_tvos/test/rcu/tv_remote_controller_test.dart @@ -272,6 +272,32 @@ void main() { expect(configureCalls.length, greaterThan(baseline)); expect(configureCalls.last['dpadDeadZone'], 0.8); }); + + test('configure handshake retries until the native handler is ready', + () async { + // Simulate native not registered yet (the AOT/release startup race): + // drop the recording handler the group's setUp installed. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(TvRemoteChannels.button, null); + + TvRemoteController.instance.debugInit(); // fires the retry loop + await Future.delayed(const Duration(milliseconds: 250)); + expect(configureCalls, isEmpty, + reason: 'no configure should land while native is unregistered'); + + // Native comes online — restore the recording handler. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(TvRemoteChannels.button, + (MethodCall call) async { + if (call.method == 'configure') { + configureCalls.add(Map.from(call.arguments as Map)); + } + return null; + }); + await Future.delayed(const Duration(milliseconds: 250)); + expect(configureCalls, isNotEmpty, + reason: 'retry should deliver configure once native registers'); + }); }); group('TvRemoteController platform guard', () { From 08581da9454f16268b9685ed83c9f7e24711390a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Ali=20Ustao=C4=9Flu?= Date: Tue, 16 Jun 2026 08:01:17 +0200 Subject: [PATCH 4/8] docs(changelog): document the AOT platform-identity fix in 1.3.1 1.3.1 was never tagged, and the platform-identity fix landed on the same branch on top of the engine bump, so the release now carries a real CLI behaviour change. Drop the "no CLI behaviour change" note, add a Fixed section for the AOT Platform.operatingSystem/isIOS/isTvOS correction, note the bundled flutter_tvos 1.1.1, and move the date to the actual ship date. --- CHANGELOG.md | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7e7d24..2288278 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,20 +2,31 @@ All notable changes to flutter-tvos will be documented here. -## [1.3.1] — 2026-06-14 +## [1.3.1] — 2026-06-16 -Patch release. Refreshes the pinned engine to **Flutter 3.44.2** -(`c9a6c484`, Dart `d684a576`). No CLI behaviour change — Flutter 3.44.2 is a -hotfix whose only tvOS-relevant content is the Dart SDK roll plus build-tool -fixes inherited from the upstream `flutter_tools`. +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. ### 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 tvOS engine artifact variants against Dart `d684a576`. This - is 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. +- 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 From 58829545d31058af1bb28822ad682f1492aad26c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Ali=20Ustao=C4=9Flu?= Date: Tue, 16 Jun 2026 08:30:59 +0200 Subject: [PATCH 5/8] fix(platform): compile profile AOT against the non-product host SDK Profile builds crashed at startup with `Type '_NetworkProfiling' not found in library 'dart.io'`. The patched-SDK override pointed both profile and release at the product host_release SDK, but the non-product profile engine looks up entry-point classes (e.g. dart:io _NetworkProfiling) that the product SDK doesn't retain through AOT tree-shaking. Map profile to the non-product host_debug_unopt SDK (which also carries the platform-identity patch), matching stock Flutter's flutter_patched_sdk (profile) vs flutter_patched_sdk_product (release) split. Release is unchanged. Verified on a physical Apple TV. --- CHANGELOG.md | 7 +++++++ lib/tvos_artifacts.dart | 15 +++++++++++--- test/general/tvos_artifacts_test.dart | 29 +++++++++++++++++---------- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2288278..1f87fc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ Refreshes the pinned engine to **Flutter 3.44.2** (`c9a6c484`, Dart 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. ### Changed - Bumped `bin/internal/flutter.version` to `c9a6c484` (Flutter 3.44.2) and diff --git a/lib/tvos_artifacts.dart b/lib/tvos_artifacts.dart index a29cd7c..9a956fe 100644 --- a/lib/tvos_artifacts.dart +++ b/lib/tvos_artifacts.dart @@ -86,10 +86,19 @@ class TvosArtifacts extends CachedArtifacts { } /// Path to the patched `flutter_patched_sdk` inside the host engine - /// artifact for [mode]. Profile and release both use `host_release`; - /// `host_debug_unopt` is only consulted for non-precompiled callers. + /// 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.debug ? 'host_debug_unopt' : 'host_release'; + final dirName = mode == BuildMode.release ? 'host_release' : 'host_debug_unopt'; // Handle the nested directory that zip extraction can produce // (`///flutter_patched_sdk`). final Directory nested = _fileSystem.directory( diff --git a/test/general/tvos_artifacts_test.dart b/test/general/tvos_artifacts_test.dart index b749386..5c603e9 100644 --- a/test/general/tvos_artifacts_test.dart +++ b/test/general/tvos_artifacts_test.dart @@ -42,22 +42,29 @@ void main() { }); group('TvosArtifacts patched-SDK override (AOT platform identity)', () { - test('release flutterPatchedSdkPath resolves to host_release patched SDK', () { - final String path = artifacts.getArtifactPath( - Artifact.flutterPatchedSdkPath, - mode: BuildMode.release, + test('release uses the product SDK (host_release)', () { + // Release rides the product SDK, matching stock flutter_patched_sdk_product. + expect( + artifacts.getArtifactPath(Artifact.flutterPatchedSdkPath, mode: BuildMode.release), + '/engine_artifacts/host_release/flutter_patched_sdk', + ); + expect( + artifacts.getArtifactPath(Artifact.platformKernelDill, mode: BuildMode.release), + '/engine_artifacts/host_release/flutter_patched_sdk/platform_strong.dill', ); - expect(path, '/engine_artifacts/host_release/flutter_patched_sdk'); }); - test('profile platformKernelDill resolves to host_release platform_strong.dill', () { - final String path = artifacts.getArtifactPath( - Artifact.platformKernelDill, - mode: BuildMode.profile, + test('profile uses the NON-product SDK (host_debug_unopt)', () { + // Profile must compile against the non-product SDK so AOT retains + // entry-point classes the profile engine looks up natively (e.g. + // dart:io _NetworkProfiling). Using the product SDK aborts at startup. + expect( + artifacts.getArtifactPath(Artifact.flutterPatchedSdkPath, mode: BuildMode.profile), + '/engine_artifacts/host_debug_unopt/flutter_patched_sdk', ); expect( - path, - '/engine_artifacts/host_release/flutter_patched_sdk/platform_strong.dill', + artifacts.getArtifactPath(Artifact.platformKernelDill, mode: BuildMode.profile), + '/engine_artifacts/host_debug_unopt/flutter_patched_sdk/platform_strong.dill', ); }); From 987f4c89b192ccf0ba8714202d056418a0e0f888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Ali=20Ustao=C4=9Flu?= Date: Tue, 16 Jun 2026 12:57:37 +0200 Subject: [PATCH 6/8] docs(changelog): note on-device debug (JIT/hot reload) fix for tvOS 26 The device-debug engine now maps JIT pages RWX up front so on-device --debug + hot reload work again on tvOS 26 (which denies the RW<->RX mprotect flip). Ships in the v1.0.0-flutter3.44.2 engine artifacts; debug-only, AOT keeps W^X. --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f87fc4..57c3b9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,15 @@ Refreshes the pinned engine to **Flutter 3.44.2** (`c9a6c484`, Dart `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 From b5bf86d2e66869f1473a11446177b7665b630123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Ali=20Ustao=C4=9Flu?= Date: Tue, 16 Jun 2026 13:06:24 +0200 Subject: [PATCH 7/8] test(artifacts): assert engine variant selection per build mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guards that debug+simulator → tvos_debug_sim_arm64, debug+device → tvos_debug_arm64, profile → tvos_profile_arm64, release → tvos_release_arm64. A wrong mapping here is how a run mode silently picks the wrong engine. --- test/general/tvos_artifacts_test.dart | 35 +++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/general/tvos_artifacts_test.dart b/test/general/tvos_artifacts_test.dart index 5c603e9..2b4387f 100644 --- a/test/general/tvos_artifacts_test.dart +++ b/test/general/tvos_artifacts_test.dart @@ -101,4 +101,39 @@ void main() { ); }); }); + + group('engine variant selection per build mode', () { + // Guards that each run mode resolves the correct engine artifact dir — a + // wrong mapping here is how debug_sim / debug / profile / release silently + // break (e.g. a device build picking up the simulator engine). + String variantFor(BuildMode mode, EnvironmentType env) { + final String p = artifacts.getArtifactPath( + Artifact.flutterFramework, + mode: mode, + environmentType: env, + ); + return p + .split('/engine_artifacts/') + .last + .split('/Flutter.framework') + .first; + } + + test('debug + simulator → tvos_debug_sim_arm64', () { + expect(variantFor(BuildMode.debug, EnvironmentType.simulator), + 'tvos_debug_sim_arm64'); + }); + test('debug + device → tvos_debug_arm64', () { + expect(variantFor(BuildMode.debug, EnvironmentType.physical), + 'tvos_debug_arm64'); + }); + test('profile + device → tvos_profile_arm64', () { + expect(variantFor(BuildMode.profile, EnvironmentType.physical), + 'tvos_profile_arm64'); + }); + test('release + device → tvos_release_arm64', () { + expect(variantFor(BuildMode.release, EnvironmentType.physical), + 'tvos_release_arm64'); + }); + }); } From 26cf7da0ba5050a17ac0e25c7b05c8948fa2c3f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Ali=20Ustao=C4=9Flu?= Date: Tue, 16 Jun 2026 18:10:36 +0200 Subject: [PATCH 8/8] rcu: report configure-handshake exhaustion + narrow retry catch (PR #24 review I1/I2) Addresses review feedback on the configure-handshake retry: - I1: _pushConfigWithRetry now only swallows the expected MissingPluginException / PlatformException. Any other error (e.g. a _config.toMap() serialization bug) propagates instead of being retried 50x and dropped indistinguishably from the startup race. On budget exhaustion it reports via FlutterError.reportError instead of giving up silently, consistent with the rest of the class. - I2: rewrote the retry test with fakeAsync + virtual-time ticks so it no longer leans on a real-wall-clock window a starved CI runner could blow. The unregistered-native state is modelled by a mock handler that throws MissingPluginException (a null handler would fall through to the real platform dispatcher, which fakeAsync can't pump). Added a test that the retry budget exhausting reports an error. --- packages/flutter_tvos/CHANGELOG.md | 6 +- .../lib/src/rcu/tv_remote_controller.dart | 36 +++++++- packages/flutter_tvos/pubspec.lock | 2 +- packages/flutter_tvos/pubspec.yaml | 3 + .../test/rcu/tv_remote_controller_test.dart | 83 ++++++++++++++----- 5 files changed, 106 insertions(+), 24 deletions(-) diff --git a/packages/flutter_tvos/CHANGELOG.md b/packages/flutter_tvos/CHANGELOG.md index 2805856..da6116a 100644 --- a/packages/flutter_tvos/CHANGELOG.md +++ b/packages/flutter_tvos/CHANGELOG.md @@ -5,7 +5,11 @@ `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. + 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 diff --git a/packages/flutter_tvos/lib/src/rcu/tv_remote_controller.dart b/packages/flutter_tvos/lib/src/rcu/tv_remote_controller.dart index 3632bfa..8f412d9 100644 --- a/packages/flutter_tvos/lib/src/rcu/tv_remote_controller.dart +++ b/packages/flutter_tvos/lib/src/rcu/tv_remote_controller.dart @@ -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'; @@ -250,6 +252,8 @@ class TvRemoteController { Future _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; @@ -260,10 +264,40 @@ class TvRemoteController { _config.toMap(), ); return; - } catch (_) { + } 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.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.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. diff --git a/packages/flutter_tvos/pubspec.lock b/packages/flutter_tvos/pubspec.lock index 9888480..f0a0382 100644 --- a/packages/flutter_tvos/pubspec.lock +++ b/packages/flutter_tvos/pubspec.lock @@ -42,7 +42,7 @@ packages: source: hosted version: "1.19.1" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" diff --git a/packages/flutter_tvos/pubspec.yaml b/packages/flutter_tvos/pubspec.yaml index 3cd3f41..c9afae9 100644 --- a/packages/flutter_tvos/pubspec.yaml +++ b/packages/flutter_tvos/pubspec.yaml @@ -18,6 +18,9 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^4.0.0 + # Deterministic virtual time for the RCU configure-handshake retry test + # (drives the 100 ms retry ticks without real wall-clock waits). + fake_async: ^1.3.0 flutter: plugin: diff --git a/packages/flutter_tvos/test/rcu/tv_remote_controller_test.dart b/packages/flutter_tvos/test/rcu/tv_remote_controller_test.dart index e4f0373..8fa638f 100644 --- a/packages/flutter_tvos/test/rcu/tv_remote_controller_test.dart +++ b/packages/flutter_tvos/test/rcu/tv_remote_controller_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:fake_async/fake_async.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -273,30 +274,70 @@ void main() { expect(configureCalls.last['dpadDeadZone'], 0.8); }); - test('configure handshake retries until the native handler is ready', - () async { - // Simulate native not registered yet (the AOT/release startup race): - // drop the recording handler the group's setUp installed. - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(TvRemoteChannels.button, null); + test('configure handshake retries until the native handler is ready', () { + // Virtual time (fakeAsync) so the 100 ms retry ticks are driven + // deterministically — no dependency on a real-wall-clock window landing + // a retry, which a starved CI runner could blow. + fakeAsync((async) { + // Simulate the AOT/release startup race deterministically: while + // `nativeReady` is false the handler throws MissingPluginException, + // exactly as the unregistered native side does. (Setting the mock + // handler to null instead would fall through to the real platform + // dispatcher, whose reply fakeAsync can't pump — the call would hang.) + bool nativeReady = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(TvRemoteChannels.button, + (MethodCall call) async { + if (!nativeReady) { + throw MissingPluginException('FlutterTvRemotePlugin not registered'); + } + if (call.method == 'configure') { + configureCalls + .add(Map.from(call.arguments as Map)); + } + return null; + }); + + TvRemoteController.instance.debugInit(); // fires the retry loop + + // Several 100 ms retry ticks pass with native unregistered — every + // attempt throws MissingPluginException and is retried; nothing lands. + async.elapse(const Duration(milliseconds: 500)); + expect(configureCalls, isEmpty, + reason: 'no configure should land while native is unregistered'); + + // Native comes online — the next retry tick succeeds. + nativeReady = true; + async.elapse(const Duration(milliseconds: 100)); + expect(configureCalls, isNotEmpty, + reason: 'retry should deliver configure once native registers'); + }); + }); - TvRemoteController.instance.debugInit(); // fires the retry loop - await Future.delayed(const Duration(milliseconds: 250)); - expect(configureCalls, isEmpty, - reason: 'no configure should land while native is unregistered'); + test('exhausting the retry budget reports instead of failing silently', () { + fakeAsync((async) { + final errors = []; + final previousOnError = FlutterError.onError; + FlutterError.onError = errors.add; + addTearDown(() => FlutterError.onError = previousOnError); - // Native comes online — restore the recording handler. - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(TvRemoteChannels.button, - (MethodCall call) async { - if (call.method == 'configure') { - configureCalls.add(Map.from(call.arguments as Map)); - } - return null; + // Native never registers — every one of the 50 attempts throws. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(TvRemoteChannels.button, + (MethodCall call) async { + throw MissingPluginException('never registers'); + }); + + TvRemoteController.instance.debugInit(); + // 50 attempts × 100 ms = 5 s; elapse well past the budget. + async.elapse(const Duration(seconds: 7)); + + expect(configureCalls, isEmpty); + expect(errors, isNotEmpty, + reason: 'budget exhaustion must surface via FlutterError.reportError'); + expect(errors.single.library, 'flutter_tvos'); + expect(errors.single.exception, isA()); }); - await Future.delayed(const Duration(milliseconds: 250)); - expect(configureCalls, isNotEmpty, - reason: 'retry should deliver configure once native registers'); }); });