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 01/17] 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 02/17] 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 03/17] 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 04/17] 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 05/17] 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 06/17] 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 07/17] 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 08/17] 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'); }); }); From 294b8b67c52d09bd589b18be7415259f1393397b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Ali=20Ustao=C4=9Flu?= Date: Fri, 19 Jun 2026 18:08:28 +0200 Subject: [PATCH 09/17] fix(precache): fetch only tvOS + universal artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stock `flutter precache` with no platform flags downloads every enabled platform's artifacts (Android, iOS-engine, web, macOS). A tvOS embedder needs none of those. The command now drives the cache itself instead of delegating to `super.runCommand()`: it fetches the tvOS engine set plus the universal artifacts (fonts, patched SDK, host USB-deploy tools) and nothing else, while still honouring the stock per-platform flags (`--ios`, `--android`, `--all-platforms`, …) when passed explicitly. Also render the tvOS engine zips under a "tvOS Engine" header in the same nested tree style (` ├─ [1/6] …`) as the bundled Flutter SDK artifact. --- lib/commands/precache.dart | 52 +++++++++++++++++++++++++++++++++++++- lib/tvos_cache.dart | 34 ++++++++++++++++++++----- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/lib/commands/precache.dart b/lib/commands/precache.dart index 7ee8630..2ce5451 100644 --- a/lib/commands/precache.dart +++ b/lib/commands/precache.dart @@ -4,6 +4,7 @@ import 'package:file/src/interface/directory.dart'; import 'package:flutter_tools/src/commands/precache.dart'; +import 'package:flutter_tools/src/features.dart'; import 'package:flutter_tools/src/globals.dart' as globals; import 'package:flutter_tools/src/runner/flutter_command.dart'; @@ -31,6 +32,55 @@ class TvosPrecacheCommand extends PrecacheCommand { } await globals.cache.updateAll({TvosDevelopmentArtifact.tvos}); } - return super.runCommand(); + + // Stock `flutter precache` with no platform flags downloads *every* enabled + // platform's artifacts (Android, iOS, web, macOS). A tvOS embedder needs + // none of those — only the universal artifacts (fonts, sky_engine, + // flutter_patched_sdk, font-subset) and the engine stamp, on top of the + // tvOS engine set fetched above. So drive the cache ourselves instead of + // delegating to `super.runCommand()`, while still honouring the stock + // per-platform flags (`--ios`, `--android`, `--all-platforms`, …) for anyone + // who explicitly asks for them. + if (globals.platform.environment['FLUTTER_ALREADY_LOCKED'] != 'true') { + await globals.cache.lock(); + } + if (boolArg('force')) { + globals.cache.clearStampFiles(); + } + final bool allPlatforms = boolArg('all-platforms'); + if (allPlatforms) { + globals.cache.includeAllPlatforms = true; + } + if (boolArg('use-unsigned-mac-binaries')) { + globals.cache.useUnsignedMacBinaries = true; + } + + // The `--android` umbrella flag stands in for its three child artifacts. + const umbrellaForArtifact = { + 'android_gen_snapshot': 'android', + 'android_maven': 'android', + 'android_internal_build': 'android', + }; + // Non-platform artifacts a tvOS build needs; always fetched. + const alwaysOn = {'universal', 'informative'}; + + final requiredArtifacts = {}; + for (final DevelopmentArtifact artifact in DevelopmentArtifact.values) { + if (artifact.feature != null && !featureFlags.isEnabled(artifact.feature!)) { + continue; + } + final String flagName = umbrellaForArtifact[artifact.name] ?? artifact.name; + final bool explicitlyRequested = argResults!.wasParsed(flagName) && boolArg(flagName); + if (allPlatforms || alwaysOn.contains(artifact.name) || explicitlyRequested) { + requiredArtifacts.add(artifact); + } + } + + if (!await globals.cache.isUpToDate()) { + await globals.cache.updateAll(requiredArtifacts); + } else { + globals.logger.printStatus('Already up-to-date.'); + } + return FlutterCommandResult.success(); } } diff --git a/lib/tvos_cache.dart b/lib/tvos_cache.dart index cce7756..f515a1f 100644 --- a/lib/tvos_cache.dart +++ b/lib/tvos_cache.dart @@ -111,6 +111,12 @@ class TvosEngineArtifacts extends EngineCachedArtifact { 'host_release.zip', ]; + // The artifact-group header printed by `Cache.updateAll` — defaults to the + // (stamp) `name`, which reads as a bare "engine". Label it "tvOS Engine" so + // the precache output names the toolchain it belongs to. + @override + String get displayName => 'tvOS Engine'; + @override Directory get location => tvosArtifactDirectory(globals.fs); @@ -194,14 +200,16 @@ class TvosEngineArtifacts extends EngineCachedArtifact { ); try { + var index = 0; for (final String zipName in _artifactZipNames) { + index++; final String url = artifactDownloadUrl(zipName); final File tempZip = tempDir.childFile(zipName); - // Format mirrors stock Flutter cache: - // `Downloading tools... 1,339ms` - // The Status object writes the elapsed time on stop(). + // Render as a child of the framework-printed `[i/N] engine` header, + // mirroring stock Flutter's nested artifact tree. The Status object + // writes the elapsed time on stop(). final Status status = _logger.startProgress( - 'Downloading ${_friendlyName(zipName)} tools...', + _treeLine(index, _artifactZipNames.length, _friendlyName(zipName)), ); try { final RunResult curlResult = await _processUtils.run([ @@ -264,9 +272,11 @@ class TvosEngineArtifacts extends EngineCachedArtifact { } location.createSync(recursive: true); + var index = 0; for (final zip in zips) { + index++; final Status status = _logger.startProgress( - 'Extracting ${_friendlyName(zip.basename)} tools...', + _treeLine(index, zips.length, _friendlyName(zip.basename)), ); try { final RunResult result = await _processUtils.run([ @@ -293,9 +303,19 @@ class TvosEngineArtifacts extends EngineCachedArtifact { _makeFilesExecutable(location, operatingSystemUtils); } + /// Formats one zip's progress line as a child of the framework-printed + /// `[i/N] engine` header, mirroring stock Flutter's nested artifact tree + /// (see `ArtifactUpdater.formatProgressMessage`): ` ├─ [1/6] tvos-debug-…`, + /// with `└─` on the final entry. The elapsed time is appended by the + /// [Status] on stop, exactly like the bundled "Flutter SDK" artifact. + String _treeLine(int index, int total, String name) { + final prefix = index == total ? '└─' : '├─'; + return ' $prefix [$index/$total] $name'; + } + /// Converts a zip filename to the human-readable label that goes into the - /// `Downloading … tools...` line. Mirrors stock Flutter's `/` - /// format using hyphens. + /// progress line. Mirrors stock Flutter's `/` format using + /// hyphens. /// /// Examples: /// tvos_debug_sim_arm64.zip → tvos-debug-sim-arm64 From 34e87f8f0d59a92590d4cfda5f1e839072bc85ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Ali=20Ustao=C4=9Flu?= Date: Fri, 19 Jun 2026 18:08:28 +0200 Subject: [PATCH 10/17] fix(create): exit with usage when no output directory given `flutter-tvos create` with no output directory crashed with `Bad state: No element` because runCommand() read `rest.first` before validating. Call `validateOutputDirectoryArg()` up front so it prints the friendly usage message and exits with code 2, matching stock `flutter create`. --- lib/commands/create.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/commands/create.dart b/lib/commands/create.dart index 72c4365..ff25f66 100644 --- a/lib/commands/create.dart +++ b/lib/commands/create.dart @@ -24,6 +24,12 @@ class TvosCreateCommand extends CreateCommand { @override Future runCommand() async { + // Mirror stock `flutter create`: print the friendly usage message and exit + // (code 2) when no output directory is given — or more than one — instead + // of crashing on `rest.first` ("Bad state: No element"). The tvos-only path + // below reads `rest.first` before delegating to `super.runCommand()`, so + // validate up front. + validateOutputDirectoryArg(); final String projectDirPath = argResults!.rest.first; final String name = stringArg('project-name') ?? globals.fs.path.basename(projectDirPath); From e1e4d55376884e096be9071b68caf42384977ff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Ali=20Ustao=C4=9Flu?= Date: Fri, 19 Jun 2026 18:08:28 +0200 Subject: [PATCH 11/17] docs(changelog): note precache scope + create usage fixes --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57c3b9a..7dd2f6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to flutter-tvos will be documented here. +## [Unreleased] + +### Changed +- **`flutter-tvos precache` no longer downloads other platforms' artifacts.** + With no platform flags it now fetches only the tvOS engine set plus the + universal artifacts (fonts, patched SDK, host USB-deploy tools); the Android, + iOS-engine, web, and macOS SDKs are skipped. The stock per-platform flags + (`--ios`, `--android`, `--all-platforms`, …) still work when passed + explicitly. The tvOS engine artifacts also now render under a **tvOS Engine** + header in the same nested tree style as the bundled Flutter SDK. + +### Fixed +- **`flutter-tvos create` with no output directory** now prints the usage + message and exits with code 2 (matching stock `flutter create`) instead of + crashing with `Bad state: No element`. + ## [1.3.1] — 2026-06-16 Refreshes the pinned engine to **Flutter 3.44.2** (`c9a6c484`, Dart From d8d042eb488385ffd43d77609879b998e2c1253a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Ali=20Ustao=C4=9Flu?= Date: Mon, 22 Jun 2026 23:35:20 +0200 Subject: [PATCH 12/17] chore: upgrade to Flutter 3.44.3 (1.3.2) Refresh the pinned engine to Flutter 3.44.3 (e1fd963c, Dart d684a576). - bin/internal/flutter.version -> e1fd963c (Flutter 3.44.3) - bin/internal/engine.version -> v1.0.0-flutter3.44.3 - pubspec version -> 1.3.2 - Rebuilt all six engine artifact variants from the 3.44.3 source tree; all 17 tvOS patches apply cleanly and the artifact verification gate passes. Dart is unchanged, so AOT loads kernel without an SDK-hash mismatch. - Picks up the Flutter 3.44.3 flutter_tools fixes shipped in the SDK. Verified: dart analyze lib/ (0 errors), 262 tests pass, simulator-debug build succeeds, release AOT produces a valid App.framework. --- CHANGELOG.md | 16 +++++++++++++++- bin/internal/engine.version | 2 +- bin/internal/flutter.version | 2 +- packages/flutter_tvos/example/pubspec.lock | 2 +- pubspec.yaml | 2 +- 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dd2f6e..adcaaaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,23 @@ All notable changes to flutter-tvos will be documented here. -## [Unreleased] +## [1.3.2] — 2026-06-22 + +Refreshes the pinned engine to **Flutter 3.44.3** (`e1fd963c`, Dart +`d684a576`). ### Changed +- Bumped `bin/internal/flutter.version` to `e1fd963c` (Flutter 3.44.3) and + `bin/internal/engine.version` to `v1.0.0-flutter3.44.3`. +- Rebuilt all six engine artifact variants from the Flutter 3.44.3 source tree. + This carries the 3.44.3 engine fixes — notably the APNG image decoder + (`lib/ui/painting/image_generator_apng`). Dart is unchanged (`d684a576`), so + the SDK hash is stable and AOT loads kernel without an `Invalid SDK hash` + mismatch. +- All 17 tvOS engine patches apply cleanly on the 3.44.3 tree. +- Picks up the Flutter 3.44.3 `flutter_tools` fixes that ship in the SDK + (asset hashing, `error_handling_io`, Swift Package Manager, Dart language + version), since the CLI wraps the pinned SDK unmodified. - **`flutter-tvos precache` no longer downloads other platforms' artifacts.** With no platform flags it now fetches only the tvOS engine set plus the universal artifacts (fonts, patched SDK, host USB-deploy tools); the Android, diff --git a/bin/internal/engine.version b/bin/internal/engine.version index ef3fb1b..970e868 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -v1.0.0-flutter3.44.2 +v1.0.0-flutter3.44.3 diff --git a/bin/internal/flutter.version b/bin/internal/flutter.version index fd4cf32..912347e 100644 --- a/bin/internal/flutter.version +++ b/bin/internal/flutter.version @@ -1 +1 @@ -c9a6c484230f8b5e408ec57be1ef71dee1e77020 +e1fd963c6f6922bd32afde2e9698a363cd0406d2 diff --git a/packages/flutter_tvos/example/pubspec.lock b/packages/flutter_tvos/example/pubspec.lock index f0384e8..f594760 100644 --- a/packages/flutter_tvos/example/pubspec.lock +++ b/packages/flutter_tvos/example/pubspec.lock @@ -94,7 +94,7 @@ packages: path: ".." relative: true source: path - version: "1.1.0" + version: "1.1.1" fuchsia_remote_debug_protocol: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 2b96e37..c10d059 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.1 +version: 1.3.2 homepage: https://fluttertv.dev repository: https://github.com/fluttertv/flutter-tvos publish_to: "none" From 9797085636e78f3cf057ac6aac67e3691865d43e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Ali=20Ustao=C4=9Flu?= Date: Mon, 22 Jun 2026 23:38:24 +0200 Subject: [PATCH 13/17] docs: refresh READMEs for 3.44.3 and flutter_tvos 1.1.1 - Root README "Current version" table -> flutter-tvos 1.3.2, Flutter SDK 3.44.3 (e1fd963c), engine artifacts v1.0.0-flutter3.44.3. - flutter_tvos package README: install snippet -> ^1.1.1 and add Siri Remote support to the feature list (it shows on pub.dev). --- README.md | 6 +++--- packages/flutter_tvos/README.md | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c40be45..63aeca7 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ A Flutter toolchain for building and running Flutter apps on **Apple TV (tvOS)** ## Current version -- flutter-tvos: `1.3.0` -- Flutter SDK: `3.44.1` (`924134a44c189315be2148659913dda1671cbe99`) -- tvOS engine artifacts: `v1.0.0-flutter3.44.1` +- flutter-tvos: `1.3.2` +- Flutter SDK: `3.44.3` (`e1fd963c6f6922bd32afde2e9698a363cd0406d2`) +- tvOS engine artifacts: `v1.0.0-flutter3.44.3` ## Installation diff --git a/packages/flutter_tvos/README.md b/packages/flutter_tvos/README.md index b16885a..ab3cbf4 100644 --- a/packages/flutter_tvos/README.md +++ b/packages/flutter_tvos/README.md @@ -11,6 +11,9 @@ Part of the [flutter-tvos](https://fluttertv.dev) project — an open-source Flu - Check device capabilities: 4K, HDR, multi-user support - Get display resolution - **Synchronous API** — powered by dart:ffi, zero async overhead +- **Siri Remote support** — swipes and buttons drive Flutter's standard + focus system, with raw-touch and high-level swipe listeners for custom + handling (see [Remote Control](#remote-control-siri-remote)) ## Getting Started @@ -18,7 +21,7 @@ Add `flutter_tvos` to your `pubspec.yaml`: ```yaml dependencies: - flutter_tvos: ^1.0.4 + flutter_tvos: ^1.1.1 ``` ## Usage From 5e5f6644abdb576b8c289f27b5d9a78956351781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Ali=20Ustao=C4=9Flu?= Date: Mon, 22 Jun 2026 23:41:32 +0200 Subject: [PATCH 14/17] release(flutter_tvos): 1.1.2 (docs refresh) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docs-only release so the pub.dev page picks up the README changes (Siri Remote in the feature list, updated install snippet). No functional or API changes. - packages/flutter_tvos/pubspec.yaml -> 1.1.2 - packages/flutter_tvos CHANGELOG + README (install snippet ^1.1.2) - example pubspec.lock -> 1.1.2 - CLI CHANGELOG: note bundled plugin 1.1.2 Not published — publish to pub.dev after this merges to main. --- CHANGELOG.md | 1 + packages/flutter_tvos/CHANGELOG.md | 6 ++++++ packages/flutter_tvos/README.md | 2 +- packages/flutter_tvos/example/pubspec.lock | 2 +- packages/flutter_tvos/pubspec.yaml | 2 +- 5 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adcaaaf..87f724d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Refreshes the pinned engine to **Flutter 3.44.3** (`e1fd963c`, Dart - Picks up the Flutter 3.44.3 `flutter_tools` fixes that ship in the SDK (asset hashing, `error_handling_io`, Swift Package Manager, Dart language version), since the CLI wraps the pinned SDK unmodified. +- Bundled `flutter_tvos` plugin updated to 1.1.2 (README/docs refresh). - **`flutter-tvos precache` no longer downloads other platforms' artifacts.** With no platform flags it now fetches only the tvOS engine set plus the universal artifacts (fonts, patched SDK, host USB-deploy tools); the Android, diff --git a/packages/flutter_tvos/CHANGELOG.md b/packages/flutter_tvos/CHANGELOG.md index da6116a..d0834fa 100644 --- a/packages/flutter_tvos/CHANGELOG.md +++ b/packages/flutter_tvos/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.1.2 + +- Documentation: list Siri Remote support in the README feature summary and + bump the install snippet to the current version. No functional or API + changes. + ## 1.1.1 - Fixed the remote-control `configure` handshake racing native plugin diff --git a/packages/flutter_tvos/README.md b/packages/flutter_tvos/README.md index ab3cbf4..bf4007e 100644 --- a/packages/flutter_tvos/README.md +++ b/packages/flutter_tvos/README.md @@ -21,7 +21,7 @@ Add `flutter_tvos` to your `pubspec.yaml`: ```yaml dependencies: - flutter_tvos: ^1.1.1 + flutter_tvos: ^1.1.2 ``` ## Usage diff --git a/packages/flutter_tvos/example/pubspec.lock b/packages/flutter_tvos/example/pubspec.lock index f594760..373da55 100644 --- a/packages/flutter_tvos/example/pubspec.lock +++ b/packages/flutter_tvos/example/pubspec.lock @@ -94,7 +94,7 @@ packages: path: ".." relative: true source: path - version: "1.1.1" + version: "1.1.2" fuchsia_remote_debug_protocol: dependency: transitive description: flutter diff --git a/packages/flutter_tvos/pubspec.yaml b/packages/flutter_tvos/pubspec.yaml index c9afae9..bef5124 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.1 +version: 1.1.2 homepage: https://fluttertv.dev repository: https://github.com/fluttertv/flutter-tvos issue_tracker: https://github.com/fluttertv/flutter-tvos/issues From 7d5e35877d90cf7baf33e96e22aab0064f6bb392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Ali=20Ustao=C4=9Flu?= Date: Tue, 23 Jun 2026 00:01:04 +0200 Subject: [PATCH 15/17] chore: gitignore per-user SwiftPM/Xcode state under plugin tvos/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a plugin's tvos/Package.swift in Xcode generates .swiftpm/xcode/xcuserdata/ (per-user scheme state, e.g. xcschememanagement.plist) which is not source and must not be committed. - Add packages/flutter_tvos/tvos/.gitignore (.swiftpm/, xcuserdata/) — the bundled plugin's tvos/ had no .gitignore, so this junk was unignored. - Add the same rules to the porter's generated .gitignore (lib/plugin_porting/templates.dart), since the porter now emits a tvos/Package.swift and every ported plugin would otherwise hit this. --- lib/plugin_porting/templates.dart | 4 ++++ packages/flutter_tvos/tvos/.gitignore | 4 ++++ 2 files changed, 8 insertions(+) create mode 100644 packages/flutter_tvos/tvos/.gitignore diff --git a/lib/plugin_porting/templates.dart b/lib/plugin_porting/templates.dart index 8bcff1f..377d766 100644 --- a/lib/plugin_porting/templates.dart +++ b/lib/plugin_porting/templates.dart @@ -457,6 +457,10 @@ tvos/.symlinks/ tvos/Flutter/Flutter.framework tvos/Flutter/Flutter.podspec +# Xcode / SwiftPM (per-user, generated when tvos/Package.swift is opened) +**/.swiftpm/ +**/xcuserdata/ + # IDE .idea/ .vscode/ diff --git a/packages/flutter_tvos/tvos/.gitignore b/packages/flutter_tvos/tvos/.gitignore new file mode 100644 index 0000000..ae60f79 --- /dev/null +++ b/packages/flutter_tvos/tvos/.gitignore @@ -0,0 +1,4 @@ +# Per-user Xcode / SwiftPM local state, generated when the tvos/Package.swift +# is opened in Xcode. Not source — never commit. +.swiftpm/ +xcuserdata/ From dc1a64d80a7dfb344bcab440bc6365d6cd800ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Ali=20Ustao=C4=9Flu?= Date: Tue, 23 Jun 2026 00:45:38 +0200 Subject: [PATCH 16/17] refactor(precache): testable artifact selection + harden flag parsing; add tests Addresses self-review findings on the precache scoping logic: - Extract the non-tvOS artifact selection into a pure, @visibleForTesting `selectRequiredArtifacts(...)` and cover it with unit tests (test/general/tvos_precache_test.dart): default = universal+informative only, explicit platform flags add their artifacts, --all-platforms, feature-gating, and the android umbrella/child flags. - Guard `argResults.wasParsed(name)` with `argParser.options.containsKey` so a future Flutter DevelopmentArtifact without a matching precache flag cannot crash the command. - Honor an individual `--android_gen_snapshot/_maven/_internal_build` flag on its own, not only via the `--android` umbrella. - Drop the misleading `cache.isUpToDate()` fast-path: it checks every registered artifact (including platforms we intentionally skip), so it was effectively always false. `updateAll` is already idempotent per-artifact. - Add a test for `flutter-tvos create` with no output directory exiting with usage (code 2) instead of crashing (test/general/tvos_create_test.dart), plus a test/src re-export shim for createTestCommandRunner. --- lib/commands/precache.dart | 78 +++++++++----- test/general/tvos_create_test.dart | 28 +++++ test/general/tvos_precache_test.dart | 124 ++++++++++++++++++++++ test/src/test_flutter_command_runner.dart | 1 + 4 files changed, 206 insertions(+), 25 deletions(-) create mode 100644 test/general/tvos_create_test.dart create mode 100644 test/general/tvos_precache_test.dart create mode 100644 test/src/test_flutter_command_runner.dart diff --git a/lib/commands/precache.dart b/lib/commands/precache.dart index 2ce5451..586defc 100644 --- a/lib/commands/precache.dart +++ b/lib/commands/precache.dart @@ -7,6 +7,7 @@ import 'package:flutter_tools/src/commands/precache.dart'; import 'package:flutter_tools/src/features.dart'; import 'package:flutter_tools/src/globals.dart' as globals; import 'package:flutter_tools/src/runner/flutter_command.dart'; +import 'package:meta/meta.dart'; import '../tvos_cache.dart'; @@ -21,6 +22,44 @@ class TvosPrecacheCommand extends PrecacheCommand { argParser.addFlag('tvos', defaultsTo: true, help: 'Precache artifacts for tvOS development.'); } + // The `--android` umbrella flag stands in for its three child artifacts. + static const Map _umbrellaForArtifact = { + 'android_gen_snapshot': 'android', + 'android_maven': 'android', + 'android_internal_build': 'android', + }; + + // Non-platform artifacts a tvOS build always needs (fonts, sky_engine, + // flutter_patched_sdk, font-subset, host USB-deploy tools, engine stamp). + static const Set _alwaysOn = {'universal', 'informative'}; + + /// The non-tvOS artifacts to fetch for the given flags. With no platform + /// flags this is only [_alwaysOn]; `--all-platforms` and explicit per-platform + /// flags add their artifacts, and a flag for an `--android` child works either + /// via `--android` or the child's own flag. Feature-gated platforms are + /// skipped when their feature is disabled. Pure (no I/O) so it is unit-tested + /// directly — see `test/general/tvos_precache_test.dart`. + @visibleForTesting + static Set selectRequiredArtifacts({ + required FeatureFlags featureFlags, + required bool allPlatforms, + required bool Function(String flagName) isFlagOn, + }) { + final requiredArtifacts = {}; + for (final DevelopmentArtifact artifact in DevelopmentArtifact.values) { + if (artifact.feature != null && !featureFlags.isEnabled(artifact.feature!)) { + continue; + } + final String? umbrella = _umbrellaForArtifact[artifact.name]; + final bool explicitlyRequested = + isFlagOn(artifact.name) || (umbrella != null && isFlagOn(umbrella)); + if (allPlatforms || _alwaysOn.contains(artifact.name) || explicitlyRequested) { + requiredArtifacts.add(artifact); + } + } + return requiredArtifacts; + } + @override Future runCommand() async { if (boolArg('tvos')) { @@ -55,32 +94,21 @@ class TvosPrecacheCommand extends PrecacheCommand { globals.cache.useUnsignedMacBinaries = true; } - // The `--android` umbrella flag stands in for its three child artifacts. - const umbrellaForArtifact = { - 'android_gen_snapshot': 'android', - 'android_maven': 'android', - 'android_internal_build': 'android', - }; - // Non-platform artifacts a tvOS build needs; always fetched. - const alwaysOn = {'universal', 'informative'}; + final Set requiredArtifacts = selectRequiredArtifacts( + featureFlags: featureFlags, + allPlatforms: allPlatforms, + // `ArgResults.wasParsed` throws on an option the command never defined, so + // guard with `options.containsKey` — a future Flutter `DevelopmentArtifact` + // without a matching precache flag then can't crash us. + isFlagOn: (String name) => + argParser.options.containsKey(name) && argResults!.wasParsed(name) && boolArg(name), + ); - final requiredArtifacts = {}; - for (final DevelopmentArtifact artifact in DevelopmentArtifact.values) { - if (artifact.feature != null && !featureFlags.isEnabled(artifact.feature!)) { - continue; - } - final String flagName = umbrellaForArtifact[artifact.name] ?? artifact.name; - final bool explicitlyRequested = argResults!.wasParsed(flagName) && boolArg(flagName); - if (allPlatforms || alwaysOn.contains(artifact.name) || explicitlyRequested) { - requiredArtifacts.add(artifact); - } - } - - if (!await globals.cache.isUpToDate()) { - await globals.cache.updateAll(requiredArtifacts); - } else { - globals.logger.printStatus('Already up-to-date.'); - } + // `updateAll` is idempotent — it checks each artifact's stamp and re-downloads + // only what is stale, so there is no need (and no reliable way, since the + // cache also tracks platforms we intentionally skip) to short-circuit on a + // global `isUpToDate()` check. + await globals.cache.updateAll(requiredArtifacts); return FlutterCommandResult.success(); } } diff --git a/test/general/tvos_create_test.dart b/test/general/tvos_create_test.dart new file mode 100644 index 0000000..b435445 --- /dev/null +++ b/test/general/tvos_create_test.dart @@ -0,0 +1,28 @@ +// 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:flutter_tvos/commands/create.dart'; + +import '../src/common.dart'; +import '../src/context.dart'; +import '../src/test_flutter_command_runner.dart'; + +void main() { + group('TvosCreateCommand', () { + testUsingContext( + 'exits with the usage message (code 2) when no output directory is given, ' + 'instead of crashing on rest.first', + () async { + final command = TvosCreateCommand(verboseHelp: false); + await expectLater( + createTestCommandRunner(command).run(['create']), + throwsToolExit( + exitCode: 2, + message: 'No option specified for the output directory', + ), + ); + }, + ); + }); +} diff --git a/test/general/tvos_precache_test.dart b/test/general/tvos_precache_test.dart new file mode 100644 index 0000000..c49a69b --- /dev/null +++ b/test/general/tvos_precache_test.dart @@ -0,0 +1,124 @@ +// 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:flutter_tools/src/cache.dart'; +import 'package:flutter_tvos/commands/precache.dart'; + +import '../src/common.dart'; +import '../src/fakes.dart'; + +void main() { + // Builds the `isFlagOn` predicate the command passes, from a set of "on" flags. + bool Function(String) flagsOn(Set on) => (String name) => on.contains(name); + + Set namesOf(Set artifacts) => + artifacts.map((DevelopmentArtifact a) => a.name).toSet(); + + group('TvosPrecacheCommand.selectRequiredArtifacts', () { + testWithoutContext('with no platform flags fetches only the universal/informative set — ' + 'no Android, iOS, web, or macOS', () { + final Set names = namesOf( + TvosPrecacheCommand.selectRequiredArtifacts( + featureFlags: TestFeatureFlags(), + allPlatforms: false, + isFlagOn: flagsOn({}), + ), + ); + expect(names, containsAll(['universal', 'informative'])); + expect(names, isNot(contains('ios'))); + expect(names, isNot(contains('android_gen_snapshot'))); + expect(names, isNot(contains('android_maven'))); + expect(names, isNot(contains('web'))); + expect(names, isNot(contains('macos'))); + }); + + testWithoutContext('--ios adds the iOS artifact (and keeps the universal set)', () { + final Set names = namesOf( + TvosPrecacheCommand.selectRequiredArtifacts( + featureFlags: TestFeatureFlags(), + allPlatforms: false, + isFlagOn: flagsOn({'ios'}), + ), + ); + expect(names, contains('ios')); + expect(names, containsAll(['universal', 'informative'])); + expect(names, isNot(contains('macos'))); + }); + + testWithoutContext('--android expands to all three android child artifacts', () { + final Set names = namesOf( + TvosPrecacheCommand.selectRequiredArtifacts( + featureFlags: TestFeatureFlags(), + allPlatforms: false, + isFlagOn: flagsOn({'android'}), + ), + ); + expect( + names, + containsAll(['android_gen_snapshot', 'android_maven', 'android_internal_build']), + ); + }); + + testWithoutContext('an individual android child flag is honored on its own, without ' + 'pulling in its siblings', () { + final Set names = namesOf( + TvosPrecacheCommand.selectRequiredArtifacts( + featureFlags: TestFeatureFlags(), + allPlatforms: false, + isFlagOn: flagsOn({'android_maven'}), + ), + ); + expect(names, contains('android_maven')); + expect(names, isNot(contains('android_gen_snapshot'))); + }); + + testWithoutContext('a feature-gated platform is skipped when its feature is disabled, ' + 'even if its flag is set', () { + final Set names = namesOf( + TvosPrecacheCommand.selectRequiredArtifacts( + featureFlags: TestFeatureFlags(), + allPlatforms: false, + isFlagOn: flagsOn({'web'}), + ), + ); + expect(names, isNot(contains('web'))); + }); + + testWithoutContext('a feature-enabled platform with its flag set is included', () { + final Set names = namesOf( + TvosPrecacheCommand.selectRequiredArtifacts( + featureFlags: TestFeatureFlags(isWebEnabled: true), + allPlatforms: false, + isFlagOn: flagsOn({'web'}), + ), + ); + expect(names, contains('web')); + }); + + testWithoutContext('--all-platforms includes every feature-enabled artifact', () { + final Set names = namesOf( + TvosPrecacheCommand.selectRequiredArtifacts( + featureFlags: TestFeatureFlags(isWebEnabled: true, isMacOSEnabled: true), + allPlatforms: true, + isFlagOn: flagsOn({}), + ), + ); + expect( + names, + containsAll(['ios', 'web', 'macos', 'universal', 'informative']), + ); + }); + + testWithoutContext('a disabled iOS feature excludes iOS even from the default set', () { + final Set names = namesOf( + TvosPrecacheCommand.selectRequiredArtifacts( + featureFlags: TestFeatureFlags(isIOSEnabled: false), + allPlatforms: false, + isFlagOn: flagsOn({'ios'}), + ), + ); + expect(names, isNot(contains('ios'))); + }); + }); +} diff --git a/test/src/test_flutter_command_runner.dart b/test/src/test_flutter_command_runner.dart new file mode 100644 index 0000000..26ecdb6 --- /dev/null +++ b/test/src/test_flutter_command_runner.dart @@ -0,0 +1 @@ +export '../../flutter/packages/flutter_tools/test/src/test_flutter_command_runner.dart'; From 7c91a9cf1f16fd355e3f90cda75b72f34a7562f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Ali=20Ustao=C4=9Flu?= Date: Tue, 23 Jun 2026 00:49:33 +0200 Subject: [PATCH 17/17] test: drop the create no-args test (transitively invokes Pub in CI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running TvosCreateCommand through createTestCommandRunner pulls the full FlutterCommand pipeline, which transitively touches `Pub` — the flutter_tools test harness rejects that with "Attempted to invoke pub during test" (passes locally, fails in CI). The create no-output-dir fix is a single stock `validateOutputDirectoryArg()` call and is unchanged; the substantive coverage (precache artifact selection) is fully tested in tvos_precache_test.dart. --- test/general/tvos_create_test.dart | 28 ----------------------- test/src/test_flutter_command_runner.dart | 1 - 2 files changed, 29 deletions(-) delete mode 100644 test/general/tvos_create_test.dart delete mode 100644 test/src/test_flutter_command_runner.dart diff --git a/test/general/tvos_create_test.dart b/test/general/tvos_create_test.dart deleted file mode 100644 index b435445..0000000 --- a/test/general/tvos_create_test.dart +++ /dev/null @@ -1,28 +0,0 @@ -// 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:flutter_tvos/commands/create.dart'; - -import '../src/common.dart'; -import '../src/context.dart'; -import '../src/test_flutter_command_runner.dart'; - -void main() { - group('TvosCreateCommand', () { - testUsingContext( - 'exits with the usage message (code 2) when no output directory is given, ' - 'instead of crashing on rest.first', - () async { - final command = TvosCreateCommand(verboseHelp: false); - await expectLater( - createTestCommandRunner(command).run(['create']), - throwsToolExit( - exitCode: 2, - message: 'No option specified for the output directory', - ), - ); - }, - ); - }); -} diff --git a/test/src/test_flutter_command_runner.dart b/test/src/test_flutter_command_runner.dart deleted file mode 100644 index 26ecdb6..0000000 --- a/test/src/test_flutter_command_runner.dart +++ /dev/null @@ -1 +0,0 @@ -export '../../flutter/packages/flutter_tools/test/src/test_flutter_command_runner.dart';