Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
69 changes: 69 additions & 0 deletions lib/build.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -200,6 +201,13 @@ Future<void> _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
Expand All @@ -213,6 +221,67 @@ Future<void> _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<String> 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<void> 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) {
Expand Down
3 changes: 2 additions & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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:
sdk: ^3.10.0
flutter: '>=1.17.0'

dependencies:
crypto: ^3.0.3
data_assets: ^0.20.0
flutter:
sdk: flutter
Expand Down
62 changes: 62 additions & 0 deletions test/build_test.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<String, dynamic>;
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}) {
Expand Down
Loading