diff --git a/CHANGELOG.md b/CHANGELOG.md index d3cf198..2389fc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.5.2 + +* Track the `impellerc` binary as a build input, so bundles rebuild whenever the engine artifacts change. +* Write an engine stamp beside each bundle, so a shared pub-cache bundle rewritten by another project (or Flutter SDK) triggers a rebuild instead of shipping a wrong-engine bundle. + ## 0.5.1 * Added a `glesLanguageVersion` option to `buildShaderBundleJson` (and diff --git a/lib/build.dart b/lib/build.dart index 722f484..998daf7 100644 --- a/lib/build.dart +++ b/lib/build.dart @@ -1,6 +1,7 @@ import 'dart:convert' as convert; import 'dart:io'; +import 'package:crypto/crypto.dart' as crypto; import 'package:data_assets/data_assets.dart'; import 'package:hooks/hooks.dart'; @@ -200,6 +201,13 @@ Future _buildShaderBundleJson({ await depfile.delete(); } + // The produced bundle is tied to the engine build that supplied `impellerc` + // (the compiler ships in lockstep with the engine artifacts), so the + // compiler binary is a build input like any shader source. Content-hashing + // it reruns this hook on engine changes that the Dart SDK version alone + // does not reveal. + dependencies.add(impellercExec); + // Declare the collected dependencies, excluding anything under the package's // `build/` output directory. Generated shaders (for example synthesized from // another format and written into `build/`) are rewritten on every build, so @@ -213,6 +221,67 @@ Future _buildShaderBundleJson({ (uri) => !uri.toFilePath(windows: false).startsWith(buildDirectory), ), ); + + // The output path is shared through the pub cache, so another project + // (possibly on a different Flutter SDK) can rewrite the same bundle file + // with a different `impellerc`. The stamp records which compiler last wrote + // the bundle; declaring it as a dependency turns such a rewrite into a hash + // mismatch that reruns this hook. Declared after the `build/` filter above + // because the stamp deliberately lives next to the bundle. + final stampUri = engineStampUriForBundle(outputBundleFilePath); + await writeEngineStampIfChanged( + stampUri: stampUri, + impellercExec: impellercExec, + glesLanguageVersion: glesLanguageVersion, + ); + buildOutput.dependencies.add(stampUri); +} + +/// The engine-stamp path for a shader bundle at [outputBundleFilePath]. +Uri engineStampUriForBundle(Uri outputBundleFilePath) => + Uri.file('${outputBundleFilePath.toFilePath()}.engine_stamp.json'); + +/// JSON stamp identifying the compiler configuration a bundle was built with. +Future engineStampJson({ + required Uri impellercExec, + int? glesLanguageVersion, +}) async { + final digest = crypto.sha256.convert( + await File.fromUri(impellercExec).readAsBytes(), + ); + return convert.json.encode({ + 'impellerc_sha256': digest.toString(), + 'gles_language_version': ?glesLanguageVersion, + }); +} + +/// Writes the engine stamp for [impellercExec] to [stampUri], skipping the +/// write when the on-disk content already matches. +/// +/// Skipping matters because hook runners flag any tracked file modified +/// during the build and would otherwise rerun the hook on every build. For +/// the same reason a genuine rewrite backdates the stamp's mtime; change +/// detection is content-hash based, so the mtime carries no information. +Future writeEngineStampIfChanged({ + required Uri stampUri, + required Uri impellercExec, + int? glesLanguageVersion, +}) async { + final contents = await engineStampJson( + impellercExec: impellercExec, + glesLanguageVersion: glesLanguageVersion, + ); + final file = File.fromUri(stampUri); + if (await file.exists() && await file.readAsString() == contents) { + return; + } + await file.writeAsString(contents); + try { + file.setLastModifiedSync(DateTime.utc(2000)); + } on FileSystemException { + // Backdating is best-effort; without it the runner reruns the hook once + // more before the stamp settles. + } } String _impellerCHelpText(Uri impellercExec) { diff --git a/pubspec.yaml b/pubspec.yaml index 988f27d..6aa0fb8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_gpu_shaders description: 'Build tools for Flutter GPU shader bundles/libraries.' -version: 0.5.1 +version: 0.5.2 homepage: https://github.com/bdero/flutter_gpu_shaders environment: @@ -8,6 +8,7 @@ environment: flutter: '>=1.17.0' dependencies: + crypto: ^3.0.3 data_assets: ^0.20.0 flutter: sdk: flutter diff --git a/test/build_test.dart b/test/build_test.dart index 804b769..03a8590 100644 --- a/test/build_test.dart +++ b/test/build_test.dart @@ -1,6 +1,7 @@ import 'dart:convert' as convert; import 'dart:io'; +import 'package:crypto/crypto.dart' as crypto; import 'package:data_assets/data_assets.dart'; import 'package:flutter_gpu_shaders/build.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -311,6 +312,67 @@ void main() { expect(asset.package, 'example_app'); }); }); + + group('engine stamp', () { + late Directory temp; + late Uri impellerc; + late Uri stamp; + + setUp(() { + temp = Directory.systemTemp.createTempSync('flutter_gpu_shaders_stamp'); + impellerc = temp.uri.resolve('impellerc'); + File.fromUri(impellerc).writeAsBytesSync([1, 2, 3, 4]); + stamp = engineStampUriForBundle(temp.uri.resolve('base.shaderbundle')); + }); + + tearDown(() => temp.deleteSync(recursive: true)); + + test('stamp content hashes the compiler binary', () async { + final contents = await engineStampJson( + impellercExec: impellerc, + glesLanguageVersion: 300, + ); + final decoded = convert.json.decode(contents) as Map; + expect( + decoded['impellerc_sha256'], + crypto.sha256.convert([1, 2, 3, 4]).toString(), + ); + expect(decoded['gles_language_version'], 300); + }); + + test('write is skipped when the content is unchanged', () async { + await writeEngineStampIfChanged( + stampUri: stamp, + impellercExec: impellerc, + ); + final file = File.fromUri(stamp); + // The fresh write is backdated so hook runners never see the stamp as + // modified during the build. + expect(file.lastModifiedSync().toUtc(), DateTime.utc(2000)); + file.setLastModifiedSync(DateTime.utc(2010)); + + await writeEngineStampIfChanged( + stampUri: stamp, + impellercExec: impellerc, + ); + expect(file.lastModifiedSync().toUtc(), DateTime.utc(2010)); + }); + + test('a changed compiler rewrites the stamp', () async { + await writeEngineStampIfChanged( + stampUri: stamp, + impellercExec: impellerc, + ); + final before = File.fromUri(stamp).readAsStringSync(); + + File.fromUri(impellerc).writeAsBytesSync([5, 6, 7, 8]); + await writeEngineStampIfChanged( + stampUri: stamp, + impellercExec: impellerc, + ); + expect(File.fromUri(stamp).readAsStringSync(), isNot(before)); + }); + }); } BuildInput _buildInput({required bool buildDataAssets}) {