diff --git a/CHANGELOG.md b/CHANGELOG.md index 068a8b0..57c3b9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,48 @@ All notable changes to flutter-tvos will be documented here. +## [1.3.1] — 2026-06-16 + +Refreshes the pinned engine to **Flutter 3.44.2** (`c9a6c484`, Dart +`d684a576`) and fixes tvOS platform identity in AOT (release/profile) builds. + +### Fixed +- **`Platform.operatingSystem` / `isIOS` / `isTvOS` are now correct in release.** + These getters carry `@pragma("vm:platform-const")`, which `gen_snapshot` + const-folds in AOT from the build's `--target-os`. Stock Flutter passes + `--target-os ios` for `TargetPlatform.ios` (which tvOS rides), so release + baked in `operatingSystem == "ios"` and `isTvOS == false` for all app and + plugin code — debug/JIT was unaffected. `TvosKernelSnapshot` now compiles AOT + with `targetOS: null` and against the patched `flutter_patched_sdk` from the + host engine artifact, so the getters resolve at runtime to `"tvos"` exactly as + debug already did. Verified on a physical Apple TV. +- **Profile builds no longer crash at startup** with `Type '_NetworkProfiling' + not found in library 'dart.io'`. Profile now compiles against the + **non-product** host SDK (`host_debug_unopt`) instead of the product + `host_release` one: the non-product engine looks up entry-point classes (e.g. + `dart:io` `_NetworkProfiling`) that the product SDK doesn't retain through AOT + tree-shaking. Mirrors stock Flutter's `flutter_patched_sdk` (profile) vs + `flutter_patched_sdk_product` (release) split. Release is unchanged. +- **On-device `--debug` (JIT + hot reload) works on tvOS 26.** tvOS 26 denies + flipping JIT code pages RW→RX via `mprotect` (even with a debugger attached), + and the iOS dual-mapping RX workaround needs Mach exception-port APIs + unavailable on tvOS, so the debug VM aborted at `StubCode::Init` + (`mprotect failed: 13`). The device-debug engine now maps JIT pages **RWX up + front** (`FLAG_write_protect_code = false`, tvOS device only) — no RW↔RX flip + to be denied — restoring hot reload on hardware. Verified on a physical Apple + TV 4K (A15, tvOS 26.5). Debug-only (W+X); AOT release/profile keep W^X; the + simulator is unaffected. + +### Changed +- Bumped `bin/internal/flutter.version` to `c9a6c484` (Flutter 3.44.2) and + `bin/internal/engine.version` to `v1.0.0-flutter3.44.2`. +- Rebuilt all six engine artifact variants against Dart `d684a576`. Required: a + Flutter-version-only bump leaves debug/simulator builds working but breaks AOT + (`gen_snapshot: Invalid SDK hash`) because the old `gen_snapshot` cannot load + kernel compiled by the new Dart SDK. +- Bundled `flutter_tvos` plugin updated to 1.1.1 (remote-control configure + handshake retry). + ## [1.3.0] — 2026-06-12 Minor release. Adds **Swift Package Manager support** for tvOS apps (the 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/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..9a956fe 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,31 @@ class TvosArtifacts extends CachedArtifacts { ); } + /// Path to the patched `flutter_patched_sdk` inside the host engine + /// artifact for [mode]. + /// + /// Release uses the **product** SDK (`host_release`); profile uses the + /// **non-product** SDK (`host_debug_unopt`). This mirrors stock Flutter's + /// `flutter_patched_sdk` (debug/profile) vs `flutter_patched_sdk_product` + /// (release) split: the non-product SDK marks entry-point classes that the + /// profile/JIT engine looks up natively — e.g. `dart:io`'s + /// `_NetworkProfiling` — so gen_snapshot keeps them through AOT + /// tree-shaking. Compiling a profile build against the product SDK drops + /// those classes and the engine aborts at startup with + /// `Type '_NetworkProfiling' not found in library 'dart.io'`. + String _hostPatchedSdkDirectory(BuildMode mode) { + final dirName = mode == BuildMode.release ? 'host_release' : 'host_debug_unopt'; + // Handle the nested directory that zip extraction can produce + // (`///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/CHANGELOG.md b/packages/flutter_tvos/CHANGELOG.md index 36df793..da6116a 100644 --- a/packages/flutter_tvos/CHANGELOG.md +++ b/packages/flutter_tvos/CHANGELOG.md @@ -1,3 +1,16 @@ +## 1.1.1 + +- Fixed the remote-control `configure` handshake racing native plugin + registration at startup. `TvRemoteController.init()` now retries the + `configure` call until the native `FlutterTvRemotePlugin` acknowledges it, + so the touchpad reliably starts forwarding swipe/touch events instead of + intermittently appearing dead when the first push landed before the native + handler was registered. The retry now only swallows the expected + `MissingPluginException` / `PlatformException` (any other error — e.g. a + config serialization bug — propagates instead of being silently retried), + and if the handshake is never acknowledged within the retry budget it + surfaces via `FlutterError.reportError` rather than giving up silently. + ## 1.1.0 - Added Swift Package Manager support. `flutter_tvos` now ships a 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/packages/flutter_tvos/lib/src/rcu/tv_remote_controller.dart b/packages/flutter_tvos/lib/src/rcu/tv_remote_controller.dart index 4517ef4..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'; @@ -187,6 +189,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 +216,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 +230,74 @@ 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; + Object? lastError; + StackTrace? lastStack; + for (int attempt = 0; attempt < maxAttempts; attempt++) { + if (generation != _configPushGeneration || !_initialized) { + return; + } + try { + await TvRemoteChannels.button.invokeMethod( + TvRemoteProtocol.methodConfigure, + _config.toMap(), + ); + return; + } on MissingPluginException catch (error, stack) { + // The handshake race this loop exists for: native + // `FlutterTvRemotePlugin` hasn't registered its handler yet. Retry. + lastError = error; + lastStack = stack; + await Future.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 cb6b2eb..c9afae9 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 @@ -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 87e7a9a..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'; @@ -272,6 +273,72 @@ void main() { expect(configureCalls.length, greaterThan(baseline)); expect(configureCalls.last['dpadDeadZone'], 0.8); }); + + 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'); + }); + }); + + 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 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()); + }); + }); }); group('TvRemoteController platform guard', () { 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" diff --git a/test/general/tvos_artifacts_test.dart b/test/general/tvos_artifacts_test.dart new file mode 100644 index 0000000..2b4387f --- /dev/null +++ b/test/general/tvos_artifacts_test.dart @@ -0,0 +1,139 @@ +// 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 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', + ); + }); + + 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( + artifacts.getArtifactPath(Artifact.platformKernelDill, mode: BuildMode.profile), + '/engine_artifacts/host_debug_unopt/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', + ); + }); + }); + + 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'); + }); + }); +} 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, + ); + }); + }); +}