diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..35125ee --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,31 @@ + + +## What does this PR do? + + + +## How was it tested? + + + +- [ ] `flutter/bin/dart test test/` passes +- [ ] `flutter/bin/dart analyze lib/` is clean +- [ ] Verified on tvOS **simulator** +- [ ] Verified on a **physical Apple TV** + +## Checklist + +- [ ] This PR targets the **`dev`** branch (not `main`) +- [ ] Added an entry to `CHANGELOG.md` under **`[Unreleased]`** (user-visible changes only; no version bump — maintainers assign versions at release) +- [ ] Added or updated tests covering the change (or explained below why none are needed) +- [ ] If the change touches generated plugin registrants or app templates: **both** the ObjC and Swift variants are updated +- [ ] No changes under `flutter/` or `engine_artifacts/` (managed directories — see CONTRIBUTING.md) + +## Notes for reviewers + + diff --git a/CHANGELOG.md b/CHANGELOG.md index bacefd4..c1a975b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,54 @@ All notable changes to flutter-tvos will be documented here. +## [Unreleased] + + + +## [1.4.1] — 2026-07-19 + +Refreshes the pinned engine to **Flutter 3.44.6** (`ee80f08b`, Dart 3.12.2). + +### Engine / SDK +- Bumped `bin/internal/flutter.version` to `ee80f08b` (Flutter 3.44.6) and + `bin/internal/engine.version` to `v1.0.1-flutter3.44.6`. +- Rebuilt and origin-signed all six engine artifact variants from the 3.44.6 + source tree, against tvOS SDK 26.5 (Xcode 26.6). +- All 17 tvOS engine patches apply unchanged: the 3.44.5 → 3.44.6 delta touches + only `flutter_tools` and the Android Gradle plugin, so no patched file moved. +- Dart revision is unchanged from 3.44.5, so this is not an SDK-hash-breaking + roll — the artifacts were rebuilt anyway to keep the engine tag and the SDK + pin in lockstep. + +### Fixed +- **Apps no longer crash on first frame on tvOS simulators older than 26.** The + simulator engine embedded Impeller metallibs compiled for the *iOS* simulator + (target triple `air64-apple-ios13.0.0-simulator`), because the shader build + assumed the tvOS simulator would load iphonesimulator libraries fine — it runs + on the host Mac GPU, after all. Metal actually validates the library's target + platform when a pipeline is created, so on a tvOS 17.5 simulator every one of + the ~60 render pipelines failed with `Target OS is incompatible` and the engine + aborted with a `raw_ptr` check failure. tvOS 26 tolerates the mismatch, which + is why it only showed up on older runtimes. The simulator engine is now built + against the AppleTVSimulator SDK (`air64-apple-tvos13.0.0-simulator`). Device + builds were never affected. Requires the engine bump to + `v1.0.1-flutter3.44.6`. (#38) +- **Debug builds launched outside `flutter-tvos run` on a physical Apple TV no + longer crash during plugin registration.** The debug engine refuses to start + without an attached debugger, so the plugin registry hands back nil + registrars — which crashed the generated registrant with an unsymbolized + SIGSEGV blaming whichever plugin registered first. The registrant now probes + the registry once, logs a clear "engine is not running" message explaining + the `flutter-tvos run` requirement, and skips registration instead of + crashing. (#37) + +### Upstream fixes picked up +- Flutter 3.44.6 hotfixes for a native-assets crash on Linux custom devices and + for jniLibs staging in the Android Gradle plugin. Neither affects tvOS builds; + they ship because the SDK pin moves as a whole. + ## [1.4.0] — 2026-07-11 Ships the last of the App Store submission blockers: the engine artifact is now diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f8865b1..c85ca1f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,15 +14,28 @@ Open an issue on [GitHub Issues](https://github.com/fluttertv/flutter-tvos/issue The more detail you provide, the faster the bug can be tracked down. +## Branching Model + +- **`main`** holds released code only. Every commit on `main` corresponds to a published release (or the release process itself). Nothing is merged into `main` directly. +- **`dev`** is the integration branch. All pull requests target `dev`. It always contains the latest `main` plus the changes queued for the next release. +- At release time, maintainers merge `dev` into `main`, tag, and publish; `main` is then merged back into `dev` so the two stay in sync. + ## Submitting a Pull Request -1. Fork the repository and create a branch from `main` +1. Fork the repository and create a branch from `dev` 2. Make your changes -3. Run the test suite and static analysis (see below) -4. Open a PR against `main` with a clear description of what changed and why +3. Add a `CHANGELOG.md` entry under `[Unreleased]` (see below) +4. Run the test suite and static analysis (see below) +5. Open a PR against **`dev`** with a clear description of what changed and why Keep PRs focused. One logical change per PR makes review faster. +## Changelog + +User-visible changes (fixes, features, behavior changes) get an entry in `CHANGELOG.md` under the `[Unreleased]` heading, written in the same style as the released sections. **Do not bump the version** in `pubspec.yaml` or add a version heading — maintainers assign version numbers when cutting a release, at which point `[Unreleased]` entries are moved into the new version's section. + +Internal-only changes (refactors, CI, test-only changes) don't need a changelog entry. + ## Running Tests The Flutter SDK is bootstrapped automatically into `flutter/` when you first run any `flutter-tvos` command. Once it is present, run the full test suite from the `flutter-tvos/` directory: diff --git a/README.md b/README.md index dfaeee9..cff284a 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.4.0` -- Flutter SDK: `3.44.5` (`f94f4fc76b4d74543ed9b085bbd75341ef65de22`) -- tvOS engine artifacts: `v1.0.1-flutter3.44.5` (origin-signed) +- flutter-tvos: `1.4.1` +- Flutter SDK: `3.44.6` (`ee80f08bbf97172ec030b8751ceab557177a34a6`) +- tvOS engine artifacts: `v1.0.1-flutter3.44.6` (origin-signed) ## Installation diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 4d6bbb2..5eb99a5 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -v1.0.1-flutter3.44.5 +v1.0.1-flutter3.44.6 diff --git a/bin/internal/flutter.version b/bin/internal/flutter.version index 067c539..06222d4 100644 --- a/bin/internal/flutter.version +++ b/bin/internal/flutter.version @@ -1 +1 @@ -f94f4fc76b4d74543ed9b085bbd75341ef65de22 \ No newline at end of file +ee80f08bbf97172ec030b8751ceab557177a34a6 \ No newline at end of file diff --git a/doc/debug-app.md b/doc/debug-app.md index e0ffbfd..e5176cb 100644 --- a/doc/debug-app.md +++ b/doc/debug-app.md @@ -103,6 +103,12 @@ xcrun devicectl device process launch --console \ `` is the UUID shown by `xcrun devicectl list devices`. The `--console` flag attaches stdout/stderr to your terminal session. +> **Note:** standalone launches like this only work for **profile and release** +> builds. A debug build launched outside `flutter-tvos run` cannot start its +> engine (the debug engine requires an attached debugger) — plugin +> registration is skipped and a `[GeneratedPluginRegistrant]` message +> explaining this appears in the console. + ## Other Resources - [Flutter debugging documentation](https://docs.flutter.dev/testing/debugging) — core Dart and Flutter debugging concepts that apply equally to tvOS. diff --git a/lib/tvos_plugins.dart b/lib/tvos_plugins.dart index f92ce79..af3b356 100644 --- a/lib/tvos_plugins.dart +++ b/lib/tvos_plugins.dart @@ -36,6 +36,13 @@ import {{name}} {{/methodChannelPlugins}} func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + // The registry returns nil registrars when the Flutter engine is not + // running (on a physical Apple TV the debug engine requires an attached + // debugger). Nil crashes plugin registration, so bail out loudly. + guard registry.registrar(forPlugin: "__flutter_tvos_engine_probe__") != nil else { + NSLog("[GeneratedPluginRegistrant] Flutter engine is not running; skipping plugin registration. Debug builds on a physical Apple TV must be launched via 'flutter-tvos run' (the debug engine requires an attached debugger).") + return + } {{#methodChannelPlugins}} {{class}}.register(with: registry.registrar(forPlugin: "{{class}}")) {{/methodChannelPlugins}} @@ -677,6 +684,25 @@ Future ensureReadyForTvosTooling(FlutterProject project) async { } } + // A nil registrar means the engine never started (on a physical Apple TV + // the debug engine requires an attached debugger — see + // doc/architecture.md). Bridged into a Swift plugin's nonnull parameter, + // that nil crashes registration with an unsymbolized SIGSEGV blaming the + // first plugin. Probe once with a synthetic key (a real plugin key would + // trip the engine's duplicate-registration assert) and bail out loudly. + final engineGuard = registrations.isEmpty + ? '' + : ' // The registry returns nil registrars when the Flutter engine is not\n' + ' // running (on a physical Apple TV the debug engine requires an attached\n' + ' // debugger). Nil crashes Swift plugin registration, so bail out loudly.\n' + ' if ([registry registrarForPlugin:@"__flutter_tvos_engine_probe__"] == nil) {\n' + ' NSLog(@"[GeneratedPluginRegistrant] Flutter engine is not running; skipping "\n' + ' @"plugin registration. Debug builds on a physical Apple TV must be "\n' + ' @"launched via \'flutter-tvos run\' (the debug engine requires an "\n' + ' @"attached debugger).");\n' + ' return;\n' + ' }\n'; + // Force-reference the C symbols of any statically-linked (SPM) FFI plugin // so the linker keeps them in the Runner binary; empty for the common case. final List ffiForcedSymbols = @@ -721,6 +747,7 @@ Future ensureReadyForTvosTooling(FlutterProject project) async { '@implementation GeneratedPluginRegistrant\n' '\n' '+ (void)registerWithRegistry:(NSObject*)registry {\n' + '$engineGuard' '$registrations' '$ffiForcedBody' '}\n' diff --git a/pubspec.yaml b/pubspec.yaml index 69871ef..4982202 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.4.0 +version: 1.4.1 homepage: https://fluttertv.dev repository: https://github.com/fluttertv/flutter-tvos publish_to: "none" diff --git a/test/general/tvos_plugins_test.dart b/test/general/tvos_plugins_test.dart index 1edd3ca..944d581 100644 --- a/test/general/tvos_plugins_test.dart +++ b/test/general/tvos_plugins_test.dart @@ -1087,6 +1087,108 @@ $symbolsYaml ); }); + group('engine guard in GeneratedPluginRegistrant.m', () { + // Seeds an app with a single method-channel plugin `gadget` plus the + // tvos/Runner/ directory the ObjC registrant is written into. + FlutterProject seedMethodChannelProject() { + final Directory projectDir = fileSystem.directory('/p')..createSync(); + projectDir.childDirectory('tvos').childDirectory('Runner').createSync(recursive: true); + projectDir.childFile('pubspec.yaml').writeAsStringSync('name: app\n'); + + final Directory pkgDir = fileSystem.directory('/pubcache/gadget') + ..createSync(recursive: true); + pkgDir.childFile('pubspec.yaml').writeAsStringSync(''' +name: gadget +flutter: + plugin: + platforms: + tvos: + pluginClass: GadgetPlugin +'''); + final Directory tvosDir = pkgDir.childDirectory('tvos')..createSync(); + tvosDir.childFile('gadget.podspec').writeAsStringSync('# podspec'); + + fileSystem.directory('/p/.dart_tool').childFile('package_config.json') + ..createSync(recursive: true) + ..writeAsStringSync( + json.encode({ + 'packages': >[ + { + 'name': 'gadget', + 'rootUri': 'file:///pubcache/gadget', + }, + ], + }), + ); + projectDir.childFile('.flutter-plugins-dependencies').writeAsStringSync( + json.encode({ + 'dependencyGraph': >[ + {'name': 'gadget'}, + ], + }), + ); + return FlutterProject.fromDirectory(projectDir); + } + + String registrantOf(FlutterProject project) => project.directory + .childDirectory('tvos') + .childDirectory('Runner') + .childFile('GeneratedPluginRegistrant.m') + .readAsStringSync(); + + testUsingContext( + 'guards registration behind an engine probe when method-channel plugins exist', + () async { + final FlutterProject project = seedMethodChannelProject(); + await ensureReadyForTvosTooling(project); + + final String m = registrantOf(project); + expect(m, contains('registrarForPlugin:@"__flutter_tvos_engine_probe__"')); + expect(m, contains('Flutter engine is not running')); + // The guard must run before any plugin registration. + expect( + m.indexOf('__flutter_tvos_engine_probe__'), + lessThan(m.indexOf('[GadgetPlugin registerWithRegistrar:')), + reason: 'engine probe must precede the first registration', + ); + }, + overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + }, + ); + + testUsingContext( + 'omits the guard when there are no method-channel plugins', + () async { + final Directory projectDir = fileSystem.directory('/p')..createSync(); + projectDir + .childDirectory('tvos') + .childDirectory('Runner') + .createSync(recursive: true); + projectDir.childFile('pubspec.yaml').writeAsStringSync('name: app\n'); + fileSystem.directory('/p/.dart_tool').childFile('package_config.json') + ..createSync(recursive: true) + ..writeAsStringSync(json.encode({'packages': []})); + projectDir.childFile('.flutter-plugins-dependencies').writeAsStringSync( + json.encode({'dependencyGraph': []}), + ); + + final FlutterProject project = FlutterProject.fromDirectory(projectDir); + await ensureReadyForTvosTooling(project); + + expect( + registrantOf(project), + isNot(contains('__flutter_tvos_engine_probe__')), + ); + }, + overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + }, + ); + }); + group('TvosPlugin.ffiSymbols', () { testWithoutContext('defaults to an empty list', () { final plugin = TvosPlugin(name: 'm', path: '/p', pluginClass: 'MPlugin');