Import IKAR messenger client and server

This commit is contained in:
sevenhill
2026-10-07 22:06:07 +03:00
commit 69ea5091eb
1951 changed files with 424546 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
.dart_tool/
build/
rust/target/
+18
View File
@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026-present dkaraush <dkaraush@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
+29
View File
@@ -0,0 +1,29 @@
# komet_tlottie
Local FFI plugin that ships [tlottie](https://github.com/dkaraush/tlottie) — a Lottie
renderer written in Rust — to every native Komet build. It carries only the native
library and its raw bindings (`TlottieBindings`); playback, the isolate pool and the
frame caches live in the app under `lib/core/media/tlottie/`.
## How it is built
`rust/` is a wrapper crate that depends on tlottie by an exact git `rev` with the
`c-api` feature and builds it as `cdylib` + `staticlib`, so the `tlottie_*` symbols of
the C ABI end up in `libkomet_tlottie`. `cargokit/` is vendored from `fjs` 3.3.0 (the
copy that also aligns Android arm64/x86_64 ELF segments to 16 KB pages) and compiles the
crate during the Flutter build with the same toolchain as `kolibri` and `fjs`;
`cargokit_options.yaml` at the app root keeps it building from source.
| Platform | Artifact | Loaded via |
|----------|----------|------------|
| Android | `libkomet_tlottie.so` per ABI, packaged into the APK | `DynamicLibrary.open('libkomet_tlottie.so')` |
| Linux | `libkomet_tlottie.so` in the bundle's `lib/` | `DynamicLibrary.open('libkomet_tlottie.so')` |
| Windows | `komet_tlottie.dll` next to the executable | `DynamicLibrary.open('komet_tlottie.dll')` |
| iOS / macOS | `libkomet_tlottie.a`, `-force_load`ed into the pod | `DynamicLibrary.process()`, falling back to `komet_tlottie.framework` |
Web has no native path; the app plays animations with the pure-Dart `lottie` package there.
## Bumping tlottie
Change `rev` in `rust/Cargo.toml`, run `cargo update -p tlottie` in `rust/`, and check
that `tlottie.h` upstream still matches the signatures in `lib/komet_tlottie.dart`.
+43
View File
@@ -0,0 +1,43 @@
group = "pw.komet.komet_tlottie"
version = "0.1.0"
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.11.1")
}
}
rootProject.allprojects {
repositories {
google()
mavenCentral()
}
}
apply plugin: "com.android.library"
android {
namespace = "pw.komet.komet_tlottie"
compileSdk = 35
ndkVersion = android.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
minSdk = 23
}
}
apply from: "../cargokit/gradle/plugin.gradle"
cargokit {
manifestDir = "../rust"
libname = "komet_tlottie"
}
+42
View File
@@ -0,0 +1,42 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
Copyright 2022 Matej Knopp
================================================================================
MIT LICENSE
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
APACHE LICENSE, VERSION 2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+11
View File
@@ -0,0 +1,11 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
Experimental repository to provide glue for seamlessly integrating cargo build
with flutter plugins and packages.
See https://matejknopp.com/post/flutter_plugin_in_rust_with_no_prebuilt_binaries/
for a tutorial on how to use Cargokit.
Example plugin available at https://github.com/irondash/hello_rust_ffi_plugin.
@@ -0,0 +1,58 @@
#!/bin/sh
set -e
BASEDIR=$(dirname "$0")
# Workaround for https://github.com/dart-lang/pub/issues/4010
BASEDIR=$(cd "$BASEDIR" ; pwd -P)
# Remove XCode SDK from path. Otherwise this breaks tool compilation when building iOS project
NEW_PATH=`echo $PATH | tr ":" "\n" | grep -v "Contents/Developer/" | tr "\n" ":"`
export PATH=${NEW_PATH%?} # remove trailing :
env
# Platform name (macosx, iphoneos, iphonesimulator)
export CARGOKIT_DARWIN_PLATFORM_NAME=$PLATFORM_NAME
# Arctive architectures (arm64, armv7, x86_64), space separated.
export CARGOKIT_DARWIN_ARCHS=$ARCHS
# Current build configuration (Debug, Release)
export CARGOKIT_CONFIGURATION=$CONFIGURATION
# Path to directory containing Cargo.toml.
export CARGOKIT_MANIFEST_DIR=$PODS_TARGET_SRCROOT/$1
# Temporary directory for build artifacts.
export CARGOKIT_TARGET_TEMP_DIR=$TARGET_TEMP_DIR
# Output directory for final artifacts.
export CARGOKIT_OUTPUT_DIR=$PODS_CONFIGURATION_BUILD_DIR/$PRODUCT_NAME
# Directory to store built tool artifacts.
export CARGOKIT_TOOL_TEMP_DIR=$TARGET_TEMP_DIR/build_tool
# Directory inside root project. Not necessarily the top level directory of root project.
export CARGOKIT_ROOT_PROJECT_DIR=$SRCROOT
FLUTTER_EXPORT_BUILD_ENVIRONMENT=(
"$PODS_ROOT/../Flutter/ephemeral/flutter_export_environment.sh" # macOS
"$PODS_ROOT/../Flutter/flutter_export_environment.sh" # iOS
)
for path in "${FLUTTER_EXPORT_BUILD_ENVIRONMENT[@]}"
do
if [[ -f "$path" ]]; then
source "$path"
fi
done
sh "$BASEDIR/run_build_tool.sh" build-pod "$@"
# Make a symlink from built framework to phony file, which will be used as input to
# build script. This should force rebuild (podspec currently doesn't support alwaysOutOfDate
# attribute on custom build phase)
ln -fs "$OBJROOT/XCBuildData/build.db" "${BUILT_PRODUCTS_DIR}/cargokit_phony"
ln -fs "${BUILT_PRODUCTS_DIR}/${EXECUTABLE_PATH}" "${BUILT_PRODUCTS_DIR}/cargokit_phony_out"
@@ -0,0 +1,123 @@
# fjs Cargokit Build Tool
This package is the local Cargokit build tool used by fjs. It is based on the
Flutter Rust Bridge Cargokit integration and is vendored here so the Flutter
plugin can build Rust artifacts without depending on a globally installed Dart
package.
The entrypoint is normally invoked through:
```sh
cargokit/run_build_tool.sh <command> ...
```
`run_build_tool.sh` creates a temporary Dart runner, compiles it to a kernel
snapshot, and invalidates that snapshot when this package changes.
## Commands
### build-pod
Used by the iOS, macOS, and shared Darwin podspecs:
```sh
cargokit/run_build_tool.sh build-pod libfjs fjs
```
Flutter and CocoaPods provide the required `CARGOKIT_*` and Xcode deployment
environment variables. The command builds the Rust crate for the active Darwin
platform and copies `libfjs.a` into the configured build output directory.
### build-gradle
Used by Android builds. Flutter/Gradle provide Android SDK, NDK, target platform,
and output directories through `CARGOKIT_*` environment variables.
### build-cmake
Used by desktop CMake-based builds. The command builds the Rust crate for the
requested desktop target and copies the produced dynamic library into the
configured output directory.
### gen-key
Generates an Ed25519 key pair for signing precompiled Cargokit artifacts:
```sh
cargokit/run_build_tool.sh gen-key
```
Store the private key in CI secrets only. Put the public key in
`libfjs/cargokit.yaml`.
### precompile-binaries
Builds release Rust artifacts, signs them, and uploads them to a GitHub Release
tag named `precompiled_<crate-hash>`:
```sh
PRIVATE_KEY=<hex-private-key> \
GITHUB_TOKEN=<github-token> \
cargokit/run_build_tool.sh precompile-binaries \
--repository fluttercandies/fjs \
--manifest-dir libfjs
```
Pass `--target <rust-triple>` one or more times to publish a subset of targets.
When omitted, the tool builds targets that are buildable on the current host and
adds Android targets when Android SDK options are provided.
### verify-binaries
Checks that every configured precompiled artifact exists and that its signature
matches `libfjs/cargokit.yaml`:
```sh
cargokit/run_build_tool.sh verify-binaries --manifest-dir libfjs
```
This command verifies per-target Cargokit artifacts. It does not validate the
SwiftPM XCFramework zip; use `tool/prepare_darwin_release.sh` for that release
path.
## Configuration
Crate-level settings live in `libfjs/cargokit.yaml`:
```yaml
precompiled_binaries:
url_prefix: https://github.com/fluttercandies/fjs/releases/download/precompiled_
public_key: <32-byte-ed25519-public-key-hex>
```
User-level settings are read from the nearest `cargokit_options.yaml` found by
walking upward from `CARGOKIT_ROOT_PROJECT_DIR`:
```yaml
use_precompiled_binaries: true
verbose_logging: false
```
Default behavior:
- If Rustup is available, builds use local Rust source.
- If Rustup is missing, builds try signed precompiled binaries.
- `use_precompiled_binaries: true` opts in to precompiled downloads even when
Rustup is available.
- `use_precompiled_binaries: false` forces local Rust builds.
## Darwin XCFramework Release Flow
CocoaPods and the Swift Package Manager artifact share the same Rust source and
Cargokit build inputs, but the XCFramework is packaged by repository scripts:
```sh
tool/build_fjs_xcframework.sh --configuration Release
tool/prepare_darwin_release.sh --configuration Release
```
`tool/build_fjs_xcframework.sh` writes a temporary options file that sets
`use_precompiled_binaries: false`, so the release XCFramework is built from the
current local Rust source. `tool/prepare_darwin_release.sh` then creates
`build/darwin-release/fjs.xcframework.zip`, writes the SwiftPM checksum, checks
Darwin package metadata, and runs `flutter pub publish --dry-run`.
@@ -0,0 +1,34 @@
# This is copied from Cargokit (which is the official way to use it currently)
# Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
# This file configures the static analysis results for your project (errors,
# warnings, and lints).
#
# This enables the 'recommended' set of lints from `package:lints`.
# This set helps identify many issues that may lead to problems when running
# or consuming Dart code, and enforces writing Dart using a single, idiomatic
# style and format.
#
# If you want a smaller set of lints you can change this to specify
# 'package:lints/core.yaml'. These are just the most critical lints
# (the recommended set includes the core lints).
# The core lints are also what is used by pub.dev for scoring packages.
include: package:lints/recommended.yaml
# Uncomment the following section to specify additional rules.
linter:
rules:
- prefer_relative_imports
- directives_ordering
# analyzer:
# exclude:
# - path/to/excluded/files/**
# For more information about the core and recommended set of lints, see
# https://dart.dev/go/core-lints
# For additional information about configuring this file, see
# https://dart.dev/guides/language/analysis-options
@@ -0,0 +1,8 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'package:build_tool/build_tool.dart' as build_tool;
void main(List<String> arguments) {
build_tool.runMain(arguments);
}
@@ -0,0 +1,8 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'src/build_tool.dart' as build_tool;
Future<void> runMain(List<String> args) async {
return build_tool.runMain(args);
}
@@ -0,0 +1,235 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:path/path.dart' as path;
import 'package:version/version.dart';
import 'target.dart';
import 'util.dart';
class AndroidEnvironment {
AndroidEnvironment({
required this.sdkPath,
required this.ndkVersion,
required this.minSdkVersion,
required this.targetTempDir,
required this.target,
});
static void clangLinkerWrapper(List<String> args) {
final clang = Platform.environment['_CARGOKIT_NDK_LINK_CLANG'];
if (clang == null) {
throw Exception(
"cargo-ndk rustc linker: didn't find _CARGOKIT_NDK_LINK_CLANG env var");
}
final target = Platform.environment['_CARGOKIT_NDK_LINK_TARGET'];
if (target == null) {
throw Exception(
"cargo-ndk rustc linker: didn't find _CARGOKIT_NDK_LINK_TARGET env var");
}
runCommand(clang, [
target,
...args,
]);
}
/// Full path to Android SDK.
final String sdkPath;
/// Full version of Android NDK.
final String ndkVersion;
/// Minimum supported SDK version.
final int minSdkVersion;
/// Target directory for build artifacts.
final String targetTempDir;
/// Target being built.
final Target target;
bool ndkIsInstalled() {
final ndkPath = path.join(sdkPath, 'ndk', ndkVersion);
final ndkPackageXml = File(path.join(ndkPath, 'package.xml'));
return ndkPackageXml.existsSync();
}
void installNdk({
required String javaHome,
}) {
final sdkManagerExtension = Platform.isWindows ? '.bat' : '';
final sdkManager = path.join(
sdkPath,
'cmdline-tools',
'latest',
'bin',
'sdkmanager$sdkManagerExtension',
);
log.info('Installing NDK $ndkVersion');
runCommand(sdkManager, [
'--install',
'ndk;$ndkVersion',
], environment: {
'JAVA_HOME': javaHome,
});
}
Future<Map<String, String>> buildEnvironment() async {
final hostArch = Platform.isMacOS
? "darwin-x86_64"
: (Platform.isLinux ? "linux-x86_64" : "windows-x86_64");
final ndkPath = path.join(sdkPath, 'ndk', ndkVersion);
final toolchainPath = path.join(
ndkPath,
'toolchains',
'llvm',
'prebuilt',
hostArch,
'bin',
);
final minSdkVersion =
math.max(target.androidMinSdkVersion!, this.minSdkVersion);
final exe = Platform.isWindows ? '.exe' : '';
final arKey = 'AR_${target.rust}';
final arValue = ['${target.rust}-ar', 'llvm-ar', 'llvm-ar.exe']
.map((e) => path.join(toolchainPath, e))
.firstWhereOrNull((element) => File(element).existsSync());
if (arValue == null) {
throw Exception('Failed to find ar for $target in $toolchainPath');
}
final targetArg = '--target=${target.rust}$minSdkVersion';
final ccKey = 'CC_${target.rust}';
final ccValue = path.join(toolchainPath, 'clang$exe');
final cfFlagsKey = 'CFLAGS_${target.rust}';
final cFlagsValue = targetArg;
final cxxKey = 'CXX_${target.rust}';
final cxxValue = path.join(toolchainPath, 'clang++$exe');
final cxxFlagsKey = 'CXXFLAGS_${target.rust}';
final cxxFlagsValue = targetArg;
final linkerKey =
'cargo_target_${target.rust.replaceAll('-', '_')}_linker'.toUpperCase();
final ranlibKey = 'RANLIB_${target.rust}';
final ranlibValue = path.join(toolchainPath, 'llvm-ranlib$exe');
final ndkVersionParsed = Version.parse(ndkVersion);
final rustFlagsKey = 'CARGO_ENCODED_RUSTFLAGS';
final rustFlagsValue = _libGccWorkaround(targetTempDir, ndkVersionParsed);
final runRustTool =
Platform.isWindows ? 'run_build_tool.cmd' : 'run_build_tool.sh';
final packagePath = (await Isolate.resolvePackageUri(
Uri.parse('package:build_tool/buildtool.dart')))!
.toFilePath();
final selfPath = path.canonicalize(path.join(
packagePath,
'..',
'..',
'..',
runRustTool,
));
// Make sure that run_build_tool is working properly even initially launched directly
// through dart run.
final toolTempDir =
Platform.environment['CARGOKIT_TOOL_TEMP_DIR'] ?? targetTempDir;
final sysroot = path.join(
ndkPath,
'toolchains',
'llvm',
'prebuilt',
hostArch,
'sysroot',
);
final bindgenKey = "BINDGEN_EXTRA_CLANG_ARGS_${target.rust}";
final sysrootForClang = _clangPath(sysroot);
final includeForClang =
_clangPath(path.join(sysroot, 'usr', 'include', target.rust));
final bindgenValue =
"$targetArg --sysroot=$sysrootForClang -I$includeForClang";
final env = {
arKey: arValue,
ccKey: ccValue,
cfFlagsKey: cFlagsValue,
cxxKey: cxxValue,
cxxFlagsKey: cxxFlagsValue,
ranlibKey: ranlibValue,
rustFlagsKey: rustFlagsValue,
linkerKey: selfPath,
bindgenKey: bindgenValue,
// Recognized by main() so we know when we're acting as a wrapper
'_CARGOKIT_NDK_LINK_TARGET': targetArg,
'_CARGOKIT_NDK_LINK_CLANG': ccValue,
'CARGOKIT_TOOL_TEMP_DIR': toolTempDir,
};
log.info(JsonEncoder.withIndent(' ').convert(env));
return env;
}
String _clangPath(String value) {
return value.replaceAll(r'\', '/');
}
// Workaround for libgcc missing in NDK23, inspired by cargo-ndk
String _libGccWorkaround(String buildDir, Version ndkVersion) {
final workaroundDir = path.join(
buildDir,
'cargokit',
'libgcc_workaround',
'${ndkVersion.major}',
);
Directory(workaroundDir).createSync(recursive: true);
if (ndkVersion.major >= 23) {
File(path.join(workaroundDir, 'libgcc.a'))
.writeAsStringSync('INPUT(-lunwind)');
} else {
// Other way around, untested, forward libgcc.a from libunwind once Rust
// gets updated for NDK23+.
File(path.join(workaroundDir, 'libunwind.a'))
.writeAsStringSync('INPUT(-lgcc)');
}
var rustFlags = Platform.environment['CARGO_ENCODED_RUSTFLAGS'] ?? '';
if (rustFlags.isNotEmpty) {
rustFlags = '$rustFlags\x1f';
}
if (["arm64-v8a", "x86_64"].contains(target.android)) {
rustFlags = '$rustFlags-L\x1f$workaroundDir\x1f';
const pageSizeArgs = [
"-C",
"link-arg=-Wl,--hash-style=both",
"-C",
"link-arg=-Wl,-z,max-page-size=16384"
];
final pageSizeArgsString = pageSizeArgs.join("\x1f");
rustFlags = '$rustFlags$pageSizeArgsString';
} else {
rustFlags = '$rustFlags-L\x1f$workaroundDir';
}
return rustFlags;
}
}
@@ -0,0 +1,305 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:http/http.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'builder.dart';
import 'crate_hash.dart';
import 'options.dart';
import 'precompile_binaries.dart';
import 'precompiled_asset_store.dart';
import 'precompiled_generation.dart';
import 'rustup.dart';
import 'target.dart';
class Artifact {
Artifact({
required this.path,
required this.finalFileName,
});
/// File system location of the artifact.
final String path;
/// Actual file name that the artifact should have in destination folder.
final String finalFileName;
AritifactType get type {
if (finalFileName.endsWith('.dll') ||
finalFileName.endsWith('.dll.lib') ||
finalFileName.endsWith('.pdb') ||
finalFileName.endsWith('.so') ||
finalFileName.endsWith('.dylib')) {
return AritifactType.dylib;
} else if (finalFileName.endsWith('.lib') || finalFileName.endsWith('.a')) {
return AritifactType.staticlib;
}
throw Exception('Unknown artifact type for $finalFileName');
}
}
typedef ArtifactHttpGet = Future<Response> Function(Uri url);
typedef ArtifactGenerationHash = String Function(BuildEnvironment environment);
typedef ArtifactLocalBuilder = Future<List<Artifact>> Function(
Target target, BuildEnvironment environment);
typedef ArtifactTransportFactory = PrecompiledAssetTransport Function();
final _log = Logger('artifacts_provider');
class ArtifactProvider {
ArtifactProvider({
required this.environment,
required this.userOptions,
ArtifactHttpGet? httpGet,
PrecompiledHttpSend? httpSend,
ArtifactGenerationHash? generationHash,
ArtifactLocalBuilder? localBuilder,
ArtifactTransportFactory? transportFactory,
PrecompiledTransportPolicy transportPolicy =
const PrecompiledTransportPolicy(),
this.storePolicy = const PrecompiledAssetStorePolicy(),
}) : generationHash = generationHash ??
((environment) => CrateHash.compute(
environment.manifestDir,
tempStorage: environment.targetTempDir,
)),
localBuilder = localBuilder ?? _defaultLocalBuilder,
transportFactory = transportFactory ??
(() => PrecompiledAssetTransport(
send: httpSend ??
(httpGet == null ? null : _sendFromGet(httpGet)),
policy: transportPolicy,
));
final BuildEnvironment environment;
final CargokitUserOptions userOptions;
final ArtifactGenerationHash generationHash;
final ArtifactLocalBuilder localBuilder;
final ArtifactTransportFactory transportFactory;
final PrecompiledAssetStorePolicy storePolicy;
Future<Map<Target, List<Artifact>>> getArtifacts(List<Target> targets) async {
final result = await _getPrecompiledArtifacts(targets);
final pendingTargets = List.of(targets)
..removeWhere((target) => result.containsKey(target));
if (pendingTargets.isEmpty) return result;
if (!userOptions.allowLocalBuild) {
throw PrecompiledGenerationException(
'Required precompiled generation did not provide all requested targets.');
}
for (final target in pendingTargets) {
_log.info('Building ${environment.crateInfo.packageName} for $target');
result[target] = await localBuilder(target, environment);
}
return result;
}
Future<Map<Target, List<Artifact>>> _getPrecompiledArtifacts(
List<Target> targets) async {
if (userOptions.precompiledBinariesMode ==
PrecompiledBinariesMode.disabled) {
_log.info('Precompiled binaries are disabled');
return {};
}
final precompiled = environment.crateOptions.precompiledBinaries;
if (precompiled == null) return {};
final recipe = precompiled.buildRecipe;
if (recipe == null) {
throw PrecompiledGenerationException(
'Configured precompiled binaries need a complete build recipe.');
}
final pinnedTargets = recipe.rustTargets.toSet();
if (targets.any((target) => !pinnedTargets.contains(target.rust))) {
throw PrecompiledGenerationException(
'Requested Rust target is not included in the signed build recipe.');
}
final requiredForTargets = <Target, List<String>>{
for (final target in targets)
target: getArtifactNames(
target: target,
libraryName: environment.crateInfo.packageName,
remote: true,
).map((name) => PrecompileBinaries.fileName(target, name)).toList(),
};
final generation = generationHash(environment);
final uriPrefix = Uri.parse(precompiled.uriPrefix);
final expectedAssetNames = _expectedAssetNames(recipe, precompiled);
final expectedCompositeChecksums = _expectedCompositeChecksums(precompiled);
final transport = transportFactory();
try {
final store = PrecompiledAssetStore(
cacheRoot: path.join(environment.targetTempDir, 'precompiled'),
uriPrefix: uriPrefix,
publicKey: precompiled.publicKey,
recipe: recipe,
expectedAssetNames: expectedAssetNames,
expectedCompositeChecksums: expectedCompositeChecksums,
transport: transport,
policy: storePolicy,
logger: _log.info,
);
final snapshot = await store.snapshot(
generationHash: generation,
requestedAssetNames:
requiredForTargets.values.expand((names) => names).toSet(),
);
if (snapshot == null) {
if (userOptions.precompiledBinariesMode ==
PrecompiledBinariesMode.auto) {
if (!store.hasAnyState(generation)) return {};
throw PrecompiledGenerationException(
'Precompiled generation manifest is unavailable for existing cache state.');
}
throw PrecompiledGenerationException(
'Required precompiled generation manifest is unavailable.');
}
return {
for (final entry in requiredForTargets.entries)
entry.key: [
for (final remoteName in entry.value)
Artifact(
path: snapshot.pathFor(remoteName),
finalFileName:
remoteName.substring('${entry.key.rust}_'.length),
),
],
};
} finally {
transport.close();
}
}
Set<String> _expectedAssetNames(
PrecompiledBuildRecipe recipe, PrecompiledBinaries precompiled) {
final names = <String>{};
for (final rustTarget in recipe.rustTargets) {
final target = Target.forRustTriple(rustTarget);
if (target == null) {
throw PrecompiledGenerationException(
'Manifest recipe has an unsupported Rust target.');
}
for (final type in AritifactType.values) {
for (final name in getArtifactNames(
target: target,
libraryName: environment.crateInfo.packageName,
remote: true,
aritifactType: type,
)) {
names.add(PrecompileBinaries.fileName(target, name));
}
}
}
for (final group in precompiled.compositeGroups) {
names.addAll(group.outputs);
}
return names;
}
static Set<String> _expectedCompositeChecksums(
PrecompiledBinaries precompiled) {
final result = <String>{};
for (final group in precompiled.compositeGroups) {
for (final output in group.outputs) {
if (!output.endsWith('.checksum')) continue;
final archive = output.substring(0, output.length - '.checksum'.length);
if (group.outputs.contains(archive)) {
result.add('$archive\u0000$output');
}
}
}
return result;
}
static PrecompiledHttpSend _sendFromGet(ArtifactHttpGet get) {
return (request) async {
final response = await get(request.url);
return StreamedResponse(
Stream.value(response.bodyBytes),
response.statusCode,
contentLength: response.bodyBytes.length,
headers: response.headers,
reasonPhrase: response.reasonPhrase,
request: request,
isRedirect: response.isRedirect,
);
};
}
}
Future<List<Artifact>> _defaultLocalBuilder(
Target target, BuildEnvironment environment) async {
final rustup = Rustup();
final builder = RustBuilder(target: target, environment: environment);
builder.prepare(rustup);
final targetDir = await builder.build();
final artifactNames = <String>{
...getArtifactNames(
target: target,
libraryName: environment.crateInfo.packageName,
aritifactType: AritifactType.dylib,
remote: false,
),
...getArtifactNames(
target: target,
libraryName: environment.crateInfo.packageName,
aritifactType: AritifactType.staticlib,
remote: false,
),
};
return artifactNames
.map((artifactName) => Artifact(
path: path.join(targetDir, artifactName),
finalFileName: artifactName,
))
.where((artifact) => File(artifact.path).existsSync())
.toList();
}
enum AritifactType {
staticlib,
dylib,
}
AritifactType artifactTypeForTarget(Target target) {
if (target.darwinPlatform != null) {
return AritifactType.staticlib;
}
return AritifactType.dylib;
}
List<String> getArtifactNames({
required Target target,
required String libraryName,
required bool remote,
AritifactType? aritifactType,
}) {
aritifactType ??= artifactTypeForTarget(target);
if (target.darwinArch != null) {
if (aritifactType == AritifactType.staticlib) {
return ['lib$libraryName.a'];
}
return ['lib$libraryName.dylib'];
} else if (target.rust.contains('-windows-')) {
if (aritifactType == AritifactType.staticlib) {
return ['$libraryName.lib'];
}
return [
'$libraryName.dll',
'$libraryName.dll.lib',
if (!remote) '$libraryName.pdb',
];
} else if (target.rust.contains('-linux-')) {
if (aritifactType == AritifactType.staticlib) {
return ['lib$libraryName.a'];
}
return ['lib$libraryName.so'];
}
throw Exception('Unsupported target: ${target.rust}');
}
@@ -0,0 +1,40 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:path/path.dart' as path;
import 'artifacts_provider.dart';
import 'builder.dart';
import 'environment.dart';
import 'options.dart';
import 'target.dart';
class BuildCMake {
final CargokitUserOptions userOptions;
BuildCMake({required this.userOptions});
Future<void> build() async {
final targetPlatform = Environment.targetPlatform;
final target = Target.forFlutterName(Environment.targetPlatform);
if (target == null) {
throw Exception("Unknown target platform: $targetPlatform");
}
final environment = BuildEnvironment.fromEnvironment(isAndroid: false);
final provider =
ArtifactProvider(environment: environment, userOptions: userOptions);
final artifacts = await provider.getArtifacts([target]);
final libs = artifacts[target]!;
for (final lib in libs) {
if (lib.type == AritifactType.dylib) {
File(lib.path)
.copySync(path.join(Environment.outputDir, lib.finalFileName));
}
}
}
}
@@ -0,0 +1,49 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'artifacts_provider.dart';
import 'builder.dart';
import 'environment.dart';
import 'options.dart';
import 'target.dart';
final log = Logger('build_gradle');
class BuildGradle {
BuildGradle({required this.userOptions});
final CargokitUserOptions userOptions;
Future<void> build() async {
final targets = Environment.targetPlatforms.map((arch) {
final target = Target.forFlutterName(arch);
if (target == null) {
throw Exception(
"Unknown darwin target or platform: $arch, ${Environment.darwinPlatformName}");
}
return target;
}).toList();
final environment = BuildEnvironment.fromEnvironment(isAndroid: true);
final provider =
ArtifactProvider(environment: environment, userOptions: userOptions);
final artifacts = await provider.getArtifacts(targets);
for (final target in targets) {
final libs = artifacts[target]!;
final outputDir = path.join(Environment.outputDir, target.android!);
Directory(outputDir).createSync(recursive: true);
for (final lib in libs) {
if (lib.type == AritifactType.dylib) {
File(lib.path).copySync(path.join(outputDir, lib.finalFileName));
}
}
}
}
}
@@ -0,0 +1,89 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:path/path.dart' as path;
import 'artifacts_provider.dart';
import 'builder.dart';
import 'environment.dart';
import 'options.dart';
import 'target.dart';
import 'util.dart';
class BuildPod {
BuildPod({required this.userOptions});
final CargokitUserOptions userOptions;
Future<void> build() async {
final targets = Environment.darwinArchs.map((arch) {
final target = Target.forDarwin(
platformName: Environment.darwinPlatformName, darwinAarch: arch);
if (target == null) {
throw Exception(
"Unknown darwin target or platform: $arch, ${Environment.darwinPlatformName}");
}
return target;
}).toList();
final environment = BuildEnvironment.fromEnvironment(isAndroid: false);
final provider =
ArtifactProvider(environment: environment, userOptions: userOptions);
final artifacts = await provider.getArtifacts(targets);
void performLipo(String targetFile, Iterable<String> sourceFiles) {
runCommand("lipo", [
'-create',
...sourceFiles,
'-output',
targetFile,
]);
}
final outputDir = Environment.outputDir;
Directory(outputDir).createSync(recursive: true);
final staticLibs = artifacts.values
.expand((element) => element)
.where((element) => element.type == AritifactType.staticlib)
.toList();
final dynamicLibs = artifacts.values
.expand((element) => element)
.where((element) => element.type == AritifactType.dylib)
.toList();
final libName = environment.crateInfo.packageName;
// If there is static lib, use it and link it with pod
if (staticLibs.isNotEmpty) {
final finalTargetFile = path.join(outputDir, "lib$libName.a");
performLipo(finalTargetFile, staticLibs.map((e) => e.path));
} else {
// Otherwise try to replace bundle dylib with our dylib
final bundlePaths = [
'$libName.framework/Versions/A/$libName',
'$libName.framework/$libName',
];
for (final bundlePath in bundlePaths) {
final targetFile = path.join(outputDir, bundlePath);
if (File(targetFile).existsSync()) {
performLipo(targetFile, dynamicLibs.map((e) => e.path));
// Replace absolute id with @rpath one so that it works properly
// when moved to Frameworks.
runCommand("install_name_tool", [
'-id',
'@rpath/$bundlePath',
targetFile,
]);
return;
}
}
throw Exception('Unable to find bundle for dynamic library');
}
}
}
@@ -0,0 +1,416 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:ed25519_edwards/ed25519_edwards.dart';
import 'package:github/github.dart';
import 'package:hex/hex.dart';
import 'package:logging/logging.dart';
import 'android_environment.dart';
import 'build_cmake.dart';
import 'build_gradle.dart';
import 'build_pod.dart';
import 'logging.dart';
import 'options.dart';
import 'precompile_binaries.dart';
import 'target.dart';
import 'util.dart';
import 'verify_binaries.dart';
final log = Logger('build_tool');
abstract class BuildCommand extends Command {
Future<void> runBuildCommand(CargokitUserOptions options);
@override
Future<void> run() async {
final options = CargokitUserOptions.load();
if (options.verboseLogging ||
Platform.environment['CARGOKIT_VERBOSE'] == '1') {
enableVerboseLogging();
}
await runBuildCommand(options);
}
}
class BuildPodCommand extends BuildCommand {
@override
final name = 'build-pod';
@override
final description = 'Build cocoa pod library';
@override
Future<void> runBuildCommand(CargokitUserOptions options) async {
final build = BuildPod(userOptions: options);
await build.build();
}
}
class BuildGradleCommand extends BuildCommand {
@override
final name = 'build-gradle';
@override
final description = 'Build android library';
@override
Future<void> runBuildCommand(CargokitUserOptions options) async {
final build = BuildGradle(userOptions: options);
await build.build();
}
}
class BuildCMakeCommand extends BuildCommand {
@override
final name = 'build-cmake';
@override
final description = 'Build CMake library';
@override
Future<void> runBuildCommand(CargokitUserOptions options) async {
final build = BuildCMake(userOptions: options);
await build.build();
}
}
class GenKeyCommand extends Command {
@override
final name = 'gen-key';
@override
final description = 'Generate key pair for signing precompiled binaries';
@override
void run() {
final kp = generateKey();
final private = HEX.encode(kp.privateKey.bytes);
final public = HEX.encode(kp.publicKey.bytes);
print("Private Key: $private");
print("Public Key: $public");
}
}
class PrecompileBinariesCommand extends Command {
PrecompileBinariesCommand() {
argParser
..addOption(
'repository',
mandatory: true,
help: 'Github repository slug in format owner/name',
)
..addOption(
'manifest-dir',
mandatory: true,
help: 'Directory containing Cargo.toml',
)
..addMultiOption('target',
help: 'Rust target triple of artifact to build.\n'
'Can be specified multiple times or omitted in which case\n'
'all targets for current platform will be built.')
..addOption(
'android-sdk-location',
help: 'Location of Android SDK (if available)',
)
..addOption(
'android-ndk-version',
help: 'Android NDK version (if available)',
)
..addOption(
'android-min-sdk-version',
help: 'Android minimum rquired version (if available)',
)
..addOption(
'temp-dir',
help: 'Directory to store temporary build artifacts',
)
..addFlag(
"verbose",
abbr: "v",
defaultsTo: false,
help: "Enable verbose logging",
);
}
@override
final name = 'precompile-binaries';
@override
final description = 'Prebuild and upload binaries\n'
'Private key must be passed through PRIVATE_KEY environment variable. '
'Use gen_key through generate priave key.\n'
'Github token must be passed as GITHUB_TOKEN environment variable.\n';
@override
Future<void> run() async {
final verbose = argResults!['verbose'] as bool;
if (verbose) {
enableVerboseLogging();
}
final privateKeyString = Platform.environment['PRIVATE_KEY'];
if (privateKeyString == null) {
throw ArgumentError('Missing PRIVATE_KEY environment variable');
}
final githubToken = Platform.environment['GITHUB_TOKEN'];
if (githubToken == null) {
throw ArgumentError('Missing GITHUB_TOKEN environment variable');
}
final privateKey = HEX.decode(privateKeyString);
if (privateKey.length != 64) {
throw ArgumentError('Private key must be 64 bytes long');
}
final manifestDir = argResults!['manifest-dir'] as String;
if (!Directory(manifestDir).existsSync()) {
throw ArgumentError('Manifest directory does not exist: $manifestDir');
}
String? androidMinSdkVersionString =
argResults!['android-min-sdk-version'] as String?;
int? androidMinSdkVersion;
if (androidMinSdkVersionString != null) {
androidMinSdkVersion = int.tryParse(androidMinSdkVersionString);
if (androidMinSdkVersion == null) {
throw ArgumentError(
'Invalid android-min-sdk-version: $androidMinSdkVersionString');
}
}
final targetStrigns = argResults!['target'] as List<String>;
final targets = targetStrigns.map((target) {
final res = Target.forRustTriple(target);
if (res == null) {
throw ArgumentError('Invalid target: $target');
}
return res;
}).toList(growable: false);
final precompileBinaries = PrecompileBinaries(
privateKey: PrivateKey(privateKey),
githubToken: githubToken,
manifestDir: manifestDir,
repositorySlug: RepositorySlug.full(argResults!['repository'] as String),
targets: targets,
androidSdkLocation: argResults!['android-sdk-location'] as String?,
androidNdkVersion: argResults!['android-ndk-version'] as String?,
androidMinSdkVersion: androidMinSdkVersion,
tempDir: argResults!['temp-dir'] as String?,
);
await precompileBinaries.run();
}
}
class BuildPrecompiledGenerationCommand extends Command {
BuildPrecompiledGenerationCommand() {
argParser
..addOption(
'manifest-dir',
mandatory: true,
help: 'Directory containing Cargo.toml and cargokit.yaml',
)
..addOption(
'output-dir',
mandatory: true,
help: 'Directory to publish the unsigned local generation',
)
..addOption(
'temp-dir',
mandatory: true,
help: 'Directory for Rust build intermediates',
)
..addMultiOption(
'target',
help: 'Pinned Rust target triple. Repeat for each target.',
);
}
@override
final name = 'build-precompiled-generation';
@override
final description =
'Builds an unsigned local precompiled generation from the pinned recipe.';
@override
Future<void> run() async {
final generation = LocalPrecompiledGeneration(
manifestDir: argResults!['manifest-dir'] as String,
outputDir: argResults!['output-dir'] as String,
tempDir: argResults!['temp-dir'] as String,
targetTriples: argResults!['target'] as List<String>,
);
await generation.run();
}
}
class FinalizeLocalGenerationCommand extends Command {
FinalizeLocalGenerationCommand() {
argParser
..addOption('manifest-dir', mandatory: true)
..addOption('generation-dir', mandatory: true)
..addOption(
'scope',
defaultsTo: 'full',
allowed: ['full', 'darwin-acceptance'],
)
..addOption(
'private-key',
help: 'Ed25519 private key hex; defaults to PRIVATE_KEY.',
)
..addOption('source-commit');
}
@override
final name = 'finalize-local-generation';
@override
final description =
'Signs and validates an unsigned local precompiled generation.';
@override
Future<void> run() async {
final encodedKey = argResults!['private-key'] as String? ??
Platform.environment['PRIVATE_KEY'];
if (encodedKey == null) {
throw ArgumentError('Missing --private-key or PRIVATE_KEY.');
}
final privateKey = HEX.decode(encodedKey);
if (privateKey.length != 64) {
throw ArgumentError('Private key must be 64 bytes long');
}
final scope = switch (argResults!['scope'] as String) {
'full' => LocalGenerationFinalizationScope.full,
'darwin-acceptance' => LocalGenerationFinalizationScope.darwinAcceptance,
_ => throw ArgumentError('Unsupported generation scope.'),
};
final finalized = await LocalGenerationFinalizer(
manifestDir: argResults!['manifest-dir'] as String,
generationDir: argResults!['generation-dir'] as String,
privateKey: PrivateKey(privateKey),
scope: scope,
sourceCommit: argResults!['source-commit'] as String?,
).run();
stdout.writeln(finalized.manifest.generationHash);
}
}
class PublishPrecompiledGenerationCommand extends Command {
PublishPrecompiledGenerationCommand() {
argParser
..addOption('manifest-dir', mandatory: true)
..addOption('generation-dir', mandatory: true)
..addOption('repository', mandatory: true)
..addOption('private-key', help: 'Ed25519 private key hex.')
..addOption('github-token',
help: 'GitHub token; defaults to GITHUB_TOKEN.')
..addOption('source-commit');
}
@override
final name = 'publish-precompiled-generation';
@override
final description = 'Finalizes and publishes one complete v2 generation.';
@override
Future<void> run() async {
final encodedKey = argResults!['private-key'] as String? ??
Platform.environment['PRIVATE_KEY'];
final token = argResults!['github-token'] as String? ??
Platform.environment['GITHUB_TOKEN'];
if (encodedKey == null || token == null) {
throw ArgumentError('Missing private key or GitHub token.');
}
final privateKey = HEX.decode(encodedKey);
if (privateKey.length != 64) {
throw ArgumentError('Private key must be 64 bytes long');
}
final github = GitHub(auth: Authentication.withToken(token));
final finalized = await LocalGenerationFinalizer(
manifestDir: argResults!['manifest-dir'] as String,
generationDir: argResults!['generation-dir'] as String,
privateKey: PrivateKey(privateKey),
scope: LocalGenerationFinalizationScope.full,
sourceCommit: argResults!['source-commit'] as String?,
).run();
await PrecompiledGenerationPublisher(
generation: finalized,
gateway: GitHubPrecompiledReleaseGateway(
repositories: github.repositories,
repository: RepositorySlug.full(argResults!['repository'] as String),
),
).publish();
}
}
class VerifyBinariesCommand extends Command {
VerifyBinariesCommand() {
argParser.addOption(
'manifest-dir',
mandatory: true,
help: 'Directory containing Cargo.toml',
);
}
@override
final name = "verify-binaries";
@override
final description = 'Verifies published binaries\n'
'Checks the complete v2 generation, assets, signatures, and checksums.';
@override
Future<void> run() async {
final manifestDir = argResults!['manifest-dir'] as String;
final verifyBinaries = VerifyBinaries(
manifestDir: manifestDir,
);
await verifyBinaries.run();
}
}
Future<void> runMain(List<String> args) async {
try {
// Init logging before options are loaded
initLogging();
if (Platform.environment['_CARGOKIT_NDK_LINK_TARGET'] != null) {
return AndroidEnvironment.clangLinkerWrapper(args);
}
final runner = CommandRunner('build_tool', 'Cargokit built_tool')
..addCommand(BuildPodCommand())
..addCommand(BuildGradleCommand())
..addCommand(BuildCMakeCommand())
..addCommand(GenKeyCommand())
..addCommand(PrecompileBinariesCommand())
..addCommand(BuildPrecompiledGenerationCommand())
..addCommand(FinalizeLocalGenerationCommand())
..addCommand(PublishPrecompiledGenerationCommand())
..addCommand(VerifyBinariesCommand());
await runner.run(args);
} on ArgumentError catch (e) {
stderr.writeln(e.toString());
exit(1);
} catch (e, s) {
log.severe(kDoubleSeparator);
log.severe('Cargokit BuildTool failed with error:');
log.severe(kSeparator);
log.severe(e);
// This tells user to install Rust, there's no need to pollute the log with
// stack trace.
if (e is! RustupNotFoundException) {
log.severe(kSeparator);
log.severe(s);
log.severe(kSeparator);
log.severe('BuildTool arguments: $args');
}
log.severe(kDoubleSeparator);
exit(1);
}
}
@@ -0,0 +1,238 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'android_environment.dart';
import 'cargo.dart';
import 'darwin_environment.dart';
import 'environment.dart';
import 'options.dart';
import 'rustup.dart';
import 'target.dart';
import 'util.dart';
final _log = Logger('builder');
enum BuildConfiguration {
debug,
release,
profile,
}
extension on BuildConfiguration {
bool get isDebug => this == BuildConfiguration.debug;
String get rustName => switch (this) {
BuildConfiguration.debug => 'debug',
BuildConfiguration.release => 'release',
BuildConfiguration.profile => 'release',
};
}
class BuildException implements Exception {
final String message;
BuildException(this.message);
@override
String toString() {
return 'BuildException: $message';
}
}
class BuildEnvironment {
final BuildConfiguration configuration;
final CargokitCrateOptions crateOptions;
final String targetTempDir;
final String manifestDir;
final CrateInfo crateInfo;
final bool isAndroid;
final String? androidSdkPath;
final String? androidNdkVersion;
final int? androidMinSdkVersion;
final String? javaHome;
final String? iosDeploymentTarget;
final String? macosDeploymentTarget;
BuildEnvironment({
required this.configuration,
required this.crateOptions,
required this.targetTempDir,
required this.manifestDir,
required this.crateInfo,
required this.isAndroid,
this.androidSdkPath,
this.androidNdkVersion,
this.androidMinSdkVersion,
this.javaHome,
this.iosDeploymentTarget,
this.macosDeploymentTarget,
});
static BuildConfiguration parseBuildConfiguration(String value) {
// XCode configuration adds the flavor to configuration name.
final firstSegment = value.split('-').first;
final buildConfiguration = BuildConfiguration.values.firstWhereOrNull(
(e) => e.name == firstSegment,
);
if (buildConfiguration == null) {
_log.warning('Unknown build configuraiton $value, will assume release');
return BuildConfiguration.release;
}
return buildConfiguration;
}
static BuildEnvironment fromEnvironment({
required bool isAndroid,
}) {
final buildConfiguration =
parseBuildConfiguration(Environment.configuration);
final manifestDir = Environment.manifestDir;
final crateOptions = CargokitCrateOptions.load(
manifestDir: manifestDir,
);
final crateInfo = CrateInfo.load(manifestDir);
return BuildEnvironment(
configuration: buildConfiguration,
crateOptions: crateOptions,
targetTempDir: Environment.targetTempDir,
manifestDir: manifestDir,
crateInfo: crateInfo,
isAndroid: isAndroid,
androidSdkPath: isAndroid ? Environment.sdkPath : null,
androidNdkVersion: isAndroid ? Environment.ndkVersion : null,
androidMinSdkVersion:
isAndroid ? int.parse(Environment.minSdkVersion) : null,
javaHome: isAndroid ? Environment.javaHome : null,
iosDeploymentTarget: isAndroid ? null : Environment.iosDeploymentTarget,
macosDeploymentTarget:
isAndroid ? null : Platform.environment['MACOSX_DEPLOYMENT_TARGET'],
);
}
}
class RustBuilder {
final Target target;
final BuildEnvironment environment;
RustBuilder({
required this.target,
required this.environment,
this.toolchain,
});
final String? toolchain;
void prepare(
Rustup rustup,
) {
final toolchain = _toolchain;
if (rustup.installedTargets(toolchain) == null) {
rustup.installToolchain(toolchain);
}
if (toolchain == 'nightly') {
rustup.installRustSrcForNightly();
}
if (!rustup.installedTargets(toolchain)!.contains(target.rust)) {
rustup.installTarget(target.rust, toolchain: toolchain);
}
}
CargoBuildOptions? get _buildOptions =>
environment.crateOptions.cargo[environment.configuration];
String get _toolchain =>
toolchain ?? _buildOptions?.toolchain.name ?? 'stable';
/// Returns the path of directory containing build artifacts.
Future<String> build() async {
final extraArgs = _buildOptions?.flags ?? [];
final manifestPath = path.join(environment.manifestDir, 'Cargo.toml');
final buildEnvironment = await _buildEnvironment();
buildEnvironment['RUSTC'] = _toolchainRustc();
runCommand(
'rustup',
[
'run',
_toolchain,
'cargo',
'build',
...extraArgs,
'--manifest-path',
manifestPath,
'-p',
environment.crateInfo.packageName,
if (!environment.configuration.isDebug) '--release',
'--target',
target.rust,
'--target-dir',
environment.targetTempDir,
],
environment: buildEnvironment,
);
return path.join(
environment.targetTempDir,
target.rust,
environment.configuration.rustName,
);
}
String _toolchainRustc() {
final result = runCommand('rustup', [
'which',
'--toolchain',
_toolchain,
'rustc',
]);
return (result.stdout as String).trim();
}
Future<Map<String, String>> _buildEnvironment() async {
if (target.android == null) {
if (target.darwinPlatform == 'macosx') {
final deploymentTarget = environment.macosDeploymentTarget;
return deploymentTarget == null
? {}
: {'MACOSX_DEPLOYMENT_TARGET': deploymentTarget};
}
if (target.darwinPlatform == 'iphonesimulator' ||
target.darwinPlatform == 'iphoneos') {
return DarwinEnvironment(
target: target,
deploymentTarget: environment.iosDeploymentTarget ??
Environment.iosDeploymentTarget,
).buildEnvironment();
}
return {};
} else {
final sdkPath = environment.androidSdkPath;
final ndkVersion = environment.androidNdkVersion;
final minSdkVersion = environment.androidMinSdkVersion;
if (sdkPath == null) {
throw BuildException('androidSdkPath is not set');
}
if (ndkVersion == null) {
throw BuildException('androidNdkVersion is not set');
}
if (minSdkVersion == null) {
throw BuildException('androidMinSdkVersion is not set');
}
final env = AndroidEnvironment(
sdkPath: sdkPath,
ndkVersion: ndkVersion,
minSdkVersion: minSdkVersion,
targetTempDir: environment.targetTempDir,
target: target,
);
if (!env.ndkIsInstalled() && environment.javaHome != null) {
env.installNdk(javaHome: environment.javaHome!);
}
return env.buildEnvironment();
}
}
}
@@ -0,0 +1,48 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:path/path.dart' as path;
import 'package:toml/toml.dart';
class ManifestException {
ManifestException(this.message, {required this.fileName});
final String? fileName;
final String message;
@override
String toString() {
if (fileName != null) {
return 'Failed to parse package manifest at $fileName: $message';
} else {
return 'Failed to parse package manifest: $message';
}
}
}
class CrateInfo {
CrateInfo({required this.packageName});
final String packageName;
static CrateInfo parseManifest(String manifest, {final String? fileName}) {
final toml = TomlDocument.parse(manifest);
final package = toml.toMap()['package'];
if (package == null) {
throw ManifestException('Missing package section', fileName: fileName);
}
final name = package['name'];
if (name == null) {
throw ManifestException('Missing package name', fileName: fileName);
}
return CrateInfo(packageName: name);
}
static CrateInfo load(String manifestDir) {
final manifestFile = File(path.join(manifestDir, 'Cargo.toml'));
final manifest = manifestFile.readAsStringSync();
return parseManifest(manifest, fileName: manifestFile.path);
}
}
@@ -0,0 +1,261 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as path;
import 'options.dart';
class CompositeArtifactException implements Exception {
CompositeArtifactException(this.message);
final String message;
@override
String toString() => 'CompositeArtifactException: $message';
}
class CompositeProcessInvocation {
const CompositeProcessInvocation({
required this.executable,
required this.arguments,
required this.workingDirectory,
required this.environment,
required this.timeout,
});
final String executable;
final List<String> arguments;
final String workingDirectory;
final Map<String, String> environment;
final Duration timeout;
}
class CompositeProcessResult {
const CompositeProcessResult({
required this.exitCode,
this.stdout = '',
this.stderr = '',
this.timedOut = false,
});
final int exitCode;
final String stdout;
final String stderr;
final bool timedOut;
}
typedef CompositeProcessRunner = Future<CompositeProcessResult> Function(
CompositeProcessInvocation invocation,
);
class CompositeArtifactResult {
CompositeArtifactResult(this.outputs);
final Map<String, String> outputs;
}
class CompositeArtifactBuilder {
CompositeArtifactBuilder({
required String workspaceRoot,
required this.generationHash,
required String targetOutputRoot,
required String outputRoot,
CompositeHost? currentHost,
CompositeProcessRunner? processRunner,
}) : workspaceRoot = path.normalize(path.absolute(workspaceRoot)),
targetOutputRoot = path.normalize(path.absolute(targetOutputRoot)),
outputRoot = path.normalize(path.absolute(outputRoot)),
currentHost = currentHost ?? _platformHost(),
processRunner = processRunner ?? _runProcess;
final String workspaceRoot;
final String generationHash;
final String targetOutputRoot;
final String outputRoot;
final CompositeHost currentHost;
final CompositeProcessRunner processRunner;
bool supports(CompositeGroup group, Set<String> targets) =>
group.host == currentHost &&
group.requiredTargets.every(targets.contains);
Future<CompositeArtifactResult> build(CompositeGroup group) async {
if (group.host != currentHost) {
throw CompositeArtifactException(
'Composite group ${group.name} requires host ${group.host.name}.',
);
}
_requireDirectory(workspaceRoot, 'Workspace root');
_requireDirectory(targetOutputRoot, 'Target output root');
for (final target in group.requiredTargets) {
final targetDirectory = path.join(targetOutputRoot, target);
_requireContainedDirectory(
targetOutputRoot,
targetDirectory,
'Required target output $target',
);
}
final executable = path.normalize(path.joinAll([
workspaceRoot,
...path.posix.split(group.argv.first),
]));
_requireContainedFile(workspaceRoot, executable, 'Composite executable');
_ensureNoSymlinkComponents(outputRoot, 'Composite output root');
Directory(outputRoot).createSync(recursive: true);
_ensureNoSymlinkComponents(outputRoot, 'Composite output root');
final staging = Directory(path.join(
outputRoot,
'.${group.name}.$pid.${DateTime.now().microsecondsSinceEpoch}',
))
..createSync();
final finalDirectory = Directory(
path.join(outputRoot, 'composites', group.name),
);
try {
final environment = <String, String>{
...group.environment,
'CARGOKIT_GENERATION_HASH': generationHash,
'CARGOKIT_WORKSPACE_ROOT': workspaceRoot,
'CARGOKIT_TARGET_OUTPUT_ROOT': targetOutputRoot,
'CARGOKIT_OUTPUT_ROOT': staging.path,
'CARGOKIT_DART_EXECUTABLE': Platform.resolvedExecutable,
};
final result = await processRunner(CompositeProcessInvocation(
executable: executable,
arguments: List.unmodifiable(group.argv.skip(1)),
workingDirectory: workspaceRoot,
environment: Map.unmodifiable(environment),
timeout: group.timeout,
));
if (result.timedOut) {
throw CompositeArtifactException(
'Composite group ${group.name} timed out.',
);
}
if (result.exitCode != 0) {
throw CompositeArtifactException(
'Composite group ${group.name} failed with exit code '
'${result.exitCode}: ${result.stderr.trim()}',
);
}
for (final output in group.outputs) {
_requireContainedFile(
staging.path,
path.join(staging.path, output),
'Composite output $output',
);
}
if (FileSystemEntity.typeSync(finalDirectory.path, followLinks: false) !=
FileSystemEntityType.notFound) {
throw CompositeArtifactException(
'Composite output already exists for ${group.name}.',
);
}
finalDirectory.parent.createSync(recursive: true);
staging.renameSync(finalDirectory.path);
return CompositeArtifactResult(Map.unmodifiable({
for (final output in group.outputs)
output: path.join(finalDirectory.path, output),
}));
} on CompositeArtifactException {
rethrow;
} on Object catch (error) {
throw CompositeArtifactException(
'Composite group ${group.name} could not be built: $error',
);
} finally {
if (staging.existsSync()) staging.deleteSync(recursive: true);
}
}
static Future<CompositeProcessResult> _runProcess(
CompositeProcessInvocation invocation,
) async {
final process = await Process.start(
invocation.executable,
invocation.arguments,
workingDirectory: invocation.workingDirectory,
environment: invocation.environment,
includeParentEnvironment: false,
runInShell: false,
);
final stdout = process.stdout.transform(utf8.decoder).join();
final stderr = process.stderr.transform(utf8.decoder).join();
var timedOut = false;
int exitCode;
try {
exitCode = await process.exitCode.timeout(invocation.timeout);
} on TimeoutException {
timedOut = true;
process.kill();
exitCode = await process.exitCode;
}
return CompositeProcessResult(
exitCode: exitCode,
stdout: await stdout,
stderr: await stderr,
timedOut: timedOut,
);
}
static CompositeHost _platformHost() {
if (Platform.isMacOS) return CompositeHost.macos;
if (Platform.isWindows) return CompositeHost.windows;
return CompositeHost.linux;
}
}
void _requireDirectory(String value, String description) {
_ensureNoSymlinkComponents(value, description);
if (FileSystemEntity.typeSync(value, followLinks: false) !=
FileSystemEntityType.directory) {
throw CompositeArtifactException('$description is not a directory: $value');
}
}
void _requireContainedDirectory(
String root,
String value,
String description,
) {
_requireContained(root, value, description);
_requireDirectory(value, description);
}
void _requireContainedFile(String root, String value, String description) {
_requireContained(root, value, description);
_ensureNoSymlinkComponents(value, description);
if (FileSystemEntity.typeSync(value, followLinks: false) !=
FileSystemEntityType.file) {
throw CompositeArtifactException('$description is not a file: $value');
}
}
void _requireContained(String root, String value, String description) {
final normalizedRoot = path.normalize(path.absolute(root));
final normalizedValue = path.normalize(path.absolute(value));
if (!path.isWithin(normalizedRoot, normalizedValue)) {
throw CompositeArtifactException('$description escapes $normalizedRoot.');
}
}
void _ensureNoSymlinkComponents(String value, String description) {
final absolute = path.normalize(path.absolute(value));
var current = path.rootPrefix(absolute);
final relative = path.relative(absolute, from: current);
if (relative == '.') return;
for (final component in path.split(relative)) {
current = path.join(current, component);
if (FileSystemEntity.typeSync(current, followLinks: false) ==
FileSystemEntityType.link) {
throw CompositeArtifactException(
'$description must not contain symbolic links: $current',
);
}
}
}
@@ -0,0 +1,293 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:convert/convert.dart';
import 'package:crypto/crypto.dart';
import 'package:path/path.dart' as path;
import 'options.dart';
enum _HashEntryType {
file,
directory,
}
class _HashEntry {
const _HashEntry({
required this.relativePath,
required this.type,
this.file,
});
final String relativePath;
final _HashEntryType type;
final File? file;
}
class CrateHash {
static const _textExtensions = {
'.rs',
'.toml',
'.yaml',
'.yml',
'.dart',
'.sh',
'.swift',
'.json',
'.md',
'.lock',
};
/// Computes a hash uniquely identifying crate content. This takes into account
/// content all all .rs files inside the src directory, as well as Cargo.toml,
/// Cargo.lock, build.rs and cargokit.yaml.
///
/// [tempStorage] is retained for call compatibility but is no longer used;
/// file content remains authoritative on every call.
static String compute(String manifestDir, {String? tempStorage}) {
return CrateHash._(
manifestDir: manifestDir,
)._compute();
}
CrateHash._({
required this.manifestDir,
});
String _compute() {
return _computeHash(_getInputs());
}
String _computeHash(List<_HashEntry> inputs) {
final output = AccumulatorSink<Digest>();
final input = sha256.startChunkedConversion(output);
final data = ByteData(8);
for (final hashInput in inputs) {
_addFramedBytes(input, [hashInput.type.index], data);
_addFramedBytes(input, utf8.encode(hashInput.relativePath), data);
_addFramedBytes(
input,
hashInput.file == null ? const [] : _canonicalContent(hashInput.file!),
data,
);
}
input.close();
final res = output.events.single;
return res.toString();
}
static List<int> _canonicalContent(File file) {
final bytes = file.readAsBytesSync();
final extension = path.extension(file.path).toLowerCase();
if (!_textExtensions.contains(extension)) {
return bytes;
}
final text = utf8.decode(bytes);
return utf8.encode(text.replaceAll('\r\n', '\n'));
}
static void _addUint64(
ByteConversionSink input,
ByteData data,
int value,
) {
data.setUint64(0, value);
input.add(data.buffer.asUint8List());
}
static void _addFramedBytes(
ByteConversionSink input,
List<int> bytes,
ByteData data,
) {
_addUint64(input, data, bytes.length);
input.add(bytes);
}
List<_HashEntry> _getInputs() {
final manifestPath = path.normalize(path.absolute(manifestDir));
_rejectAbsoluteSymlinkComponents(manifestPath, 'Crate manifest path');
final precompiled = CargokitCrateOptions.load(manifestDir: manifestPath)
.precompiledBinaries;
final configuredWorkspaceRoot = path.normalize(path.absolute(path.join(
manifestPath,
precompiled?.workspaceRoot ?? '.',
)));
_rejectAbsoluteSymlinkComponents(
configuredWorkspaceRoot,
'Workspace root',
);
final workspaceRoot =
Directory(configuredWorkspaceRoot).resolveSymbolicLinksSync();
final resolvedManifest = Directory(manifestPath).resolveSymbolicLinksSync();
if (workspaceRoot != resolvedManifest &&
!path.isWithin(workspaceRoot, resolvedManifest)) {
throw FileSystemException(
'Workspace root must contain the crate manifest.',
configuredWorkspaceRoot,
);
}
final inputs = <String, _HashEntry>{};
void addFile(File file) {
final absolute = path.normalize(path.absolute(file.path));
if (absolute != workspaceRoot &&
!path.isWithin(workspaceRoot, absolute)) {
throw FileSystemException(
'Hash input escapes the workspace root.',
absolute,
);
}
final relative = _normalizedRelativePath(absolute, workspaceRoot);
inputs[relative] = _HashEntry(
relativePath: relative,
type: _HashEntryType.file,
file: file,
);
}
void addDirectory(Directory directory) {
final absolute = path.normalize(path.absolute(directory.path));
if (absolute != workspaceRoot &&
!path.isWithin(workspaceRoot, absolute)) {
throw FileSystemException(
'Hash input escapes the workspace root.',
absolute,
);
}
final relative = _normalizedRelativePath(absolute, workspaceRoot);
inputs[relative] = _HashEntry(
relativePath: relative,
type: _HashEntryType.directory,
);
}
void addEntity(FileSystemEntity entity) {
final type = FileSystemEntity.typeSync(entity.path, followLinks: false);
if (type == FileSystemEntityType.link) {
throw FileSystemException(
'Symbolic links are not allowed in generation hash inputs.',
entity.path,
);
}
if (type == FileSystemEntityType.file) {
addFile(File(entity.path));
return;
}
if (type == FileSystemEntityType.directory) {
addDirectory(Directory(entity.path));
final children = Directory(entity.path).listSync(followLinks: false)
..sort((left, right) => left.path.compareTo(right.path));
for (final child in children) {
addEntity(child);
}
return;
}
if (type == FileSystemEntityType.notFound) {
throw FileSystemException(
'Generation hash input does not exist.', entity.path);
}
throw FileSystemException(
'Unsupported generation hash input type.',
entity.path,
);
}
final src = Directory(path.join(resolvedManifest, 'src'));
addEntity(src);
void addOptionalManifestFile(String relative) {
final filePath = path.join(resolvedManifest, relative);
final type = FileSystemEntity.typeSync(filePath, followLinks: false);
if (type == FileSystemEntityType.notFound) {
return;
}
addEntity(File(filePath));
}
addOptionalManifestFile('Cargo.toml');
addOptionalManifestFile('Cargo.lock');
addOptionalManifestFile('build.rs');
addOptionalManifestFile('cargokit.yaml');
for (final declaredInput in precompiled?.hashInputs ?? const <String>[]) {
final absolute = path.normalize(path.joinAll([
workspaceRoot,
...path.posix.split(declaredInput),
]));
if (absolute != workspaceRoot &&
!path.isWithin(workspaceRoot, absolute)) {
throw FileSystemException(
'Declared hash input escapes the workspace root.',
declaredInput,
);
}
_rejectSymlinkComponents(workspaceRoot, absolute);
addEntity(FileSystemEntity.isDirectorySync(absolute)
? Directory(absolute)
: File(absolute));
}
final result = inputs.values.toList()
..sort((left, right) => left.relativePath.compareTo(right.relativePath));
return result;
}
static String _normalizedRelativePath(String absolute, String root) {
final relative = path.relative(absolute, from: root);
return path.posix.joinAll(path.split(relative));
}
static void _rejectSymlinkComponents(String root, String target) {
var current = root;
for (final component in path.split(path.relative(target, from: root))) {
current = path.join(current, component);
_rejectLink(current, 'Generation hash input');
}
}
static void _rejectAbsoluteSymlinkComponents(
String target,
String description,
) {
final root = path.rootPrefix(target);
var current = root;
final relative = path.relative(target, from: root);
if (relative == '.') {
_rejectLink(current, description);
return;
}
for (final component in path.split(relative)) {
current = path.join(current, component);
_rejectLink(current, description);
}
}
static void _rejectLink(String entityPath, String description) {
if (FileSystemEntity.typeSync(entityPath, followLinks: false) ==
FileSystemEntityType.link) {
throw FileSystemException(
'$description must not contain symbolic links.',
entityPath,
);
}
}
List<File> getFiles() {
return _getInputs()
.map((input) => input.file)
.whereType<File>()
.toList(growable: false);
}
final String manifestDir;
}
@@ -0,0 +1,86 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'target.dart';
import 'util.dart';
class DarwinEnvironment {
DarwinEnvironment({
required this.target,
required this.deploymentTarget,
});
final Target target;
final String deploymentTarget;
Map<String, String> buildEnvironment() {
final platform = target.darwinPlatform;
if (platform == null || platform == 'macosx') {
return {};
}
final sdkPath = _resolveSdkPath(platform);
final flags = _clangFlags(sdkPath);
final rustTarget = target.rust;
return {
'SDKROOT': sdkPath,
'IPHONEOS_DEPLOYMENT_TARGET': deploymentTarget,
'CC_$rustTarget': _findTool(platform, 'clang'),
'CFLAGS_$rustTarget': flags,
'CXX_$rustTarget': _findTool(platform, 'clang++'),
'CXXFLAGS_$rustTarget': flags,
'AR_$rustTarget': _findTool(platform, 'ar'),
'RANLIB_$rustTarget': _findTool(platform, 'ranlib'),
'BINDGEN_EXTRA_CLANG_ARGS_$rustTarget': flags,
};
}
String _resolveSdkPath(String platform) {
final result = runCommand('xcrun', [
'--sdk',
platform,
'--show-sdk-path',
]);
return (result.stdout as String).trim();
}
String _findTool(String platform, String tool) {
final result = runCommand('xcrun', [
'--sdk',
platform,
'--find',
tool,
]);
return (result.stdout as String).trim();
}
String _clangFlags(String sdkPath) {
final flags = <String>[];
final clangTarget = _clangTarget();
if (clangTarget != null) {
flags.add('--target=$clangTarget');
}
flags.add('-isysroot $sdkPath');
flags.add(_deploymentFlag());
return flags.join(' ');
}
String _deploymentFlag() {
return switch (target.darwinPlatform) {
'iphonesimulator' => '-mios-simulator-version-min=$deploymentTarget',
'iphoneos' => '-miphoneos-version-min=$deploymentTarget',
_ => throw ArgumentError(
'Unsupported Darwin platform: ${target.darwinPlatform}'),
};
}
String? _clangTarget() {
return switch (target.rust) {
'aarch64-apple-ios-sim' => 'arm64-apple-ios-simulator',
'x86_64-apple-ios' => 'x86_64-apple-ios-simulator',
'aarch64-apple-ios' => 'arm64-apple-ios',
_ => null,
};
}
}
@@ -0,0 +1,72 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
extension on String {
String resolveSymlink() => File(this).resolveSymbolicLinksSync();
}
class Environment {
/// Current build configuration (debug or release).
static String get configuration =>
_getEnv("CARGOKIT_CONFIGURATION").toLowerCase();
static bool get isDebug => configuration == 'debug';
static bool get isRelease => configuration == 'release';
/// Temporary directory where Rust build artifacts are placed.
static String get targetTempDir => _getEnv("CARGOKIT_TARGET_TEMP_DIR");
/// Final output directory where the build artifacts are placed.
static String get outputDir => _getEnvPath('CARGOKIT_OUTPUT_DIR');
/// Path to the crate manifest (containing Cargo.toml).
static String get manifestDir => _getEnvPath('CARGOKIT_MANIFEST_DIR');
/// Directory inside root project. Not necessarily root folder. Symlinks are
/// not resolved on purpose.
static String get rootProjectDir => _getEnv('CARGOKIT_ROOT_PROJECT_DIR');
// Pod
/// Platform name (macosx, iphoneos, iphonesimulator).
static String get darwinPlatformName =>
_getEnv("CARGOKIT_DARWIN_PLATFORM_NAME");
/// List of architectures to build for (arm64, armv7, x86_64).
static List<String> get darwinArchs =>
_getEnv("CARGOKIT_DARWIN_ARCHS").split(' ');
static String get iosDeploymentTarget =>
Platform.environment['IPHONEOS_DEPLOYMENT_TARGET'] ?? '12.0';
// Gradle
static String get minSdkVersion => _getEnv("CARGOKIT_MIN_SDK_VERSION");
static String get ndkVersion => _getEnv("CARGOKIT_NDK_VERSION");
static String get sdkPath => _getEnvPath("CARGOKIT_SDK_DIR");
static String get javaHome => _getEnvPath("CARGOKIT_JAVA_HOME");
static List<String> get targetPlatforms =>
_getEnv("CARGOKIT_TARGET_PLATFORMS").split(',');
// CMAKE
static String get targetPlatform => _getEnv("CARGOKIT_TARGET_PLATFORM");
static String _getEnv(String key) {
final res = Platform.environment[key];
print("Environment variable $key: $res");
if (res == null) {
throw Exception("Missing environment variable $key");
}
return res;
}
static String _getEnvPath(String key) {
final res = _getEnv(key);
if (Directory(res).existsSync()) {
return res.resolveSymlink();
} else {
return res;
}
}
}
@@ -0,0 +1,52 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:logging/logging.dart';
const String kSeparator = "--";
const String kDoubleSeparator = "==";
bool _lastMessageWasSeparator = false;
void _log(LogRecord rec) {
final prefix = '${rec.level.name}: ';
final out = rec.level == Level.SEVERE ? stderr : stdout;
if (rec.message == kSeparator) {
if (!_lastMessageWasSeparator) {
out.write(prefix);
out.writeln('-' * 80);
_lastMessageWasSeparator = true;
}
return;
} else if (rec.message == kDoubleSeparator) {
out.write(prefix);
out.writeln('=' * 80);
_lastMessageWasSeparator = true;
return;
}
out.write(prefix);
out.writeln(rec.message);
_lastMessageWasSeparator = false;
}
void initLogging() {
Logger.root.level = Level.INFO;
Logger.root.onRecord.listen((LogRecord rec) {
final lines = rec.message.split('\n');
for (final line in lines) {
if (line.isNotEmpty || lines.length == 1 || line != lines.last) {
_log(LogRecord(
rec.level,
line,
rec.loggerName,
));
}
}
});
}
void enableVerboseLogging() {
Logger.root.level = Level.ALL;
}
@@ -0,0 +1,760 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:ed25519_edwards/ed25519_edwards.dart';
import 'package:hex/hex.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'package:source_span/source_span.dart';
import 'package:yaml/yaml.dart';
import 'builder.dart';
import 'environment.dart';
import 'rustup.dart';
import 'target.dart';
final _log = Logger('options');
/// A class for exceptions that have source span information attached.
class SourceSpanException implements Exception {
// This is a getter so that subclasses can override it.
/// A message describing the exception.
String get message => _message;
final String _message;
// This is a getter so that subclasses can override it.
/// The span associated with this exception.
///
/// This may be `null` if the source location can't be determined.
SourceSpan? get span => _span;
final SourceSpan? _span;
SourceSpanException(this._message, this._span);
/// Returns a string representation of `this`.
///
/// [color] may either be a [String], a [bool], or `null`. If it's a string,
/// it indicates an ANSI terminal color escape that should be used to
/// highlight the span's text. If it's `true`, it indicates that the text
/// should be highlighted using the default color. If it's `false` or `null`,
/// it indicates that the text shouldn't be highlighted.
@override
String toString({Object? color}) {
if (span == null) return message;
return 'Error on ${span!.message(message, color: color)}';
}
}
enum Toolchain {
stable,
beta,
nightly,
}
class CargoBuildOptions {
final Toolchain toolchain;
final List<String> flags;
CargoBuildOptions({
required this.toolchain,
required this.flags,
});
static Toolchain _toolchainFromNode(YamlNode node) {
if (node case YamlScalar(value: String name)) {
final toolchain =
Toolchain.values.firstWhereOrNull((element) => element.name == name);
if (toolchain != null) {
return toolchain;
}
}
throw SourceSpanException(
'Unknown toolchain. Must be one of ${Toolchain.values.map((e) => e.name)}.',
node.span);
}
static CargoBuildOptions parse(YamlNode node) {
if (node is! YamlMap) {
throw SourceSpanException('Cargo options must be a map', node.span);
}
Toolchain toolchain = Toolchain.stable;
List<String> flags = [];
for (final MapEntry(:key, :value) in node.nodes.entries) {
if (key case YamlScalar(value: 'toolchain')) {
toolchain = _toolchainFromNode(value);
} else if (key case YamlScalar(value: 'extra_flags')) {
if (value case YamlList(nodes: List<YamlNode> list)) {
if (list.every((element) {
if (element case YamlScalar(value: String _)) {
return true;
}
return false;
})) {
flags = list.map((e) => e.value as String).toList();
continue;
}
}
throw SourceSpanException(
'Extra flags must be a list of strings', value.span);
} else {
throw SourceSpanException(
'Unknown cargo option type. Must be "toolchain" or "extra_flags".',
key.span);
}
}
return CargoBuildOptions(toolchain: toolchain, flags: flags);
}
}
void _rejectUnknownFields(
YamlMap map,
Set<String> allowedFields,
String context,
) {
for (final key in map.nodes.keys) {
if (key is! YamlScalar ||
key.value is! String ||
!allowedFields.contains(key.value)) {
throw SourceSpanException(
'Unknown $context field "${key.value}".', key.span);
}
}
}
YamlMap _mapFromNode(YamlNode node, String context) {
if (node is YamlMap) {
return node;
}
throw SourceSpanException('$context must be a map.', node.span);
}
YamlNode _requiredNode(YamlMap map, String key, String context) {
final node = map.nodes[YamlScalar.wrap(key)];
if (node != null) {
return node;
}
throw SourceSpanException(
'$context must contain "$key".',
map.span,
);
}
String _stringFromNode(YamlNode node, String description,
{bool allowEmpty = false}) {
if (node case YamlScalar(value: String value)) {
if (allowEmpty || value.isNotEmpty) {
return value;
}
}
throw SourceSpanException(
'$description must be a non-empty string.', node.span);
}
List<String> _stringListFromNode(
YamlNode node,
String description, {
bool allowEmpty = false,
}) {
if (node is YamlList) {
final result = <String>[];
for (final item in node.nodes) {
result.add(_stringFromNode(item, '$description entry'));
}
if (allowEmpty || result.isNotEmpty) {
return List.unmodifiable(result);
}
}
throw SourceSpanException(
'$description must be a non-empty list of strings.',
node.span,
);
}
Map<String, String> _stringMapFromNode(
YamlNode node,
String description, {
bool allowEmpty = false,
bool allowEmptyValues = false,
}) {
if (node is YamlMap) {
final result = <String, String>{};
for (final MapEntry(:key, :value) in node.nodes.entries) {
final name = _stringFromNode(key, '$description key');
result[name] = _stringFromNode(
value,
'$description value',
allowEmpty: allowEmptyValues,
);
}
if (allowEmpty || result.isNotEmpty) {
return Map.unmodifiable(result);
}
}
throw SourceSpanException(
'$description must be a map of strings to strings.',
node.span,
);
}
String _relativePathFromNode(
YamlNode node,
String description, {
bool allowParent = false,
}) {
final value = _stringFromNode(node, description);
if (value.contains('\\') ||
path.posix.isAbsolute(value) ||
path.windows.isAbsolute(value)) {
throw SourceSpanException(
'$description must be a relative path.', node.span);
}
final normalized = path.posix.normalize(value);
if (!allowParent && (normalized == '..' || normalized.startsWith('../'))) {
throw SourceSpanException(
'$description must not escape the workspace root.',
node.span,
);
}
return normalized;
}
List<String> _rustTargetsFromNode(YamlNode node, String description) {
final targets = _stringListFromNode(node, description);
final seen = <String>{};
for (final target in targets) {
if (Target.forRustTriple(target) == null) {
throw SourceSpanException('Invalid Rust target "$target".', node.span);
}
if (!seen.add(target)) {
throw SourceSpanException('Duplicate Rust target "$target".', node.span);
}
}
return targets;
}
enum CompositeHost {
linux,
macos,
windows,
}
class PrecompiledBuildRecipe {
PrecompiledBuildRecipe({
required this.rustToolchain,
required this.flutterVersion,
required this.xcodeVersion,
required this.sdkVersions,
required this.deploymentTargets,
required this.rustTargets,
});
static PrecompiledBuildRecipe parse(YamlNode node) {
final map = _mapFromNode(node, 'Build recipe');
_rejectUnknownFields(
map,
const {
'rust_toolchain',
'flutter_version',
'xcode_version',
'sdk_versions',
'deployment_targets',
'rust_targets',
},
'build recipe');
return PrecompiledBuildRecipe(
rustToolchain: _stringFromNode(
_requiredNode(map, 'rust_toolchain', 'Build recipe'),
'Rust toolchain',
),
flutterVersion: _stringFromNode(
_requiredNode(map, 'flutter_version', 'Build recipe'),
'Flutter version',
),
xcodeVersion: _stringFromNode(
_requiredNode(map, 'xcode_version', 'Build recipe'),
'Xcode version',
),
sdkVersions: _stringMapFromNode(
_requiredNode(map, 'sdk_versions', 'Build recipe'),
'SDK versions',
),
deploymentTargets: _stringMapFromNode(
_requiredNode(map, 'deployment_targets', 'Build recipe'),
'Deployment targets',
),
rustTargets: _rustTargetsFromNode(
_requiredNode(map, 'rust_targets', 'Build recipe'),
'Build recipe Rust targets',
),
);
}
final String rustToolchain;
final String flutterVersion;
final String xcodeVersion;
final Map<String, String> sdkVersions;
final Map<String, String> deploymentTargets;
final List<String> rustTargets;
}
class CompositeGroup {
CompositeGroup({
required this.name,
required this.host,
required this.requiredTargets,
required this.argv,
required this.environment,
required this.timeout,
required this.outputs,
});
static CompositeGroup parse(YamlNode node) {
final map = _mapFromNode(node, 'Composite group');
_rejectUnknownFields(
map,
const {
'name',
'host',
'required_targets',
'argv',
'environment',
'timeout_seconds',
'outputs',
},
'composite group');
final nameNode = _requiredNode(map, 'name', 'Composite group');
final name = _safeNameFromNode(nameNode, 'Composite group name');
final hostNode = _requiredNode(map, 'host', 'Composite group');
final hostName = _stringFromNode(hostNode, 'Composite host');
final host = CompositeHost.values
.firstWhereOrNull((element) => element.name == hostName);
if (host == null) {
throw SourceSpanException(
'Invalid composite host "$hostName". Must be one of '
'${CompositeHost.values.map((host) => host.name)}.',
hostNode.span,
);
}
final argvNode = _requiredNode(map, 'argv', 'Composite group');
final argv = _stringListFromNode(argvNode, 'Composite argv');
final argvList = argvNode as YamlList;
final command = _relativePathFromNode(argvList.nodes.first, 'Command path');
final normalizedArgv =
List<String>.unmodifiable([command, ...argv.skip(1)]);
final timeoutNode =
_requiredNode(map, 'timeout_seconds', 'Composite group');
final timeoutSeconds = switch (timeoutNode) {
YamlScalar(value: int value) when value > 0 => value,
_ => throw SourceSpanException(
'Composite timeout must be a positive integer.', timeoutNode.span),
};
final outputsNode = _requiredNode(map, 'outputs', 'Composite group');
final outputNodes = switch (outputsNode) {
YamlList(nodes: final nodes) when nodes.isNotEmpty => nodes,
_ => throw SourceSpanException(
'Composite outputs must be a non-empty list.', outputsNode.span),
};
final outputs = <String>[];
final seenOutputs = <String>{};
for (final outputNode in outputNodes) {
final output = _safeNameFromNode(outputNode, 'Composite output name');
if (!seenOutputs.add(output)) {
throw SourceSpanException(
'Duplicate composite output "$output".',
outputNode.span,
);
}
outputs.add(output);
}
final environmentNode = map.nodes[YamlScalar.wrap('environment')];
final environment = environmentNode == null
? const <String, String>{}
: _stringMapFromNode(
environmentNode,
'Composite environment',
allowEmpty: true,
allowEmptyValues: true,
);
for (final key in environment.keys) {
if (!RegExp(r'^[A-Za-z_][A-Za-z0-9_]*$').hasMatch(key)) {
throw SourceSpanException(
'Invalid composite environment variable "$key".',
environmentNode?.span,
);
}
}
return CompositeGroup(
name: name,
host: host,
requiredTargets: _rustTargetsFromNode(
_requiredNode(map, 'required_targets', 'Composite group'),
'Composite required targets',
),
argv: normalizedArgv,
environment: environment,
timeout: Duration(seconds: timeoutSeconds),
outputs: List.unmodifiable(outputs),
);
}
static String _safeNameFromNode(YamlNode node, String description) {
final value = _stringFromNode(node, description);
if (!RegExp(r'^[A-Za-z0-9][A-Za-z0-9._-]*$').hasMatch(value) ||
value == '.' ||
value == '..') {
throw SourceSpanException('$description is unsafe.', node.span);
}
return value;
}
final String name;
final CompositeHost host;
final List<String> requiredTargets;
final List<String> argv;
final Map<String, String> environment;
final Duration timeout;
final List<String> outputs;
}
class PrecompiledBinaries {
final String uriPrefix;
final PublicKey publicKey;
final String workspaceRoot;
final List<String> hashInputs;
final PrecompiledBuildRecipe? buildRecipe;
final List<CompositeGroup> compositeGroups;
PrecompiledBinaries({
required this.uriPrefix,
required this.publicKey,
this.workspaceRoot = '.',
this.hashInputs = const [],
this.buildRecipe,
this.compositeGroups = const [],
});
static PublicKey _publicKeyFromHex(String key, SourceSpan? span) {
final bytes = HEX.decode(key);
if (bytes.length != 32) {
throw SourceSpanException(
'Invalid public key. Must be 32 bytes long.', span);
}
return PublicKey(bytes);
}
static PrecompiledBinaries parse(YamlNode node) {
final map = _mapFromNode(node, 'Precompiled binaries');
_rejectUnknownFields(
map,
const {
'url_prefix',
'public_key',
'workspace_root',
'hash_inputs',
'build_recipe',
'composite_groups',
},
'precompiled binaries');
final urlPrefixNode =
_requiredNode(map, 'url_prefix', 'Precompiled binaries');
final publicKeyNode =
_requiredNode(map, 'public_key', 'Precompiled binaries');
final workspaceRootNode = map.nodes[YamlScalar.wrap('workspace_root')];
final hashInputsNode = map.nodes[YamlScalar.wrap('hash_inputs')];
final buildRecipeNode = map.nodes[YamlScalar.wrap('build_recipe')];
final compositeGroupsNode = map.nodes[YamlScalar.wrap('composite_groups')];
final hashInputs = <String>[];
final seenHashInputs = <String>{};
if (hashInputsNode != null) {
if (hashInputsNode is! YamlList) {
throw SourceSpanException(
'Hash inputs must be a list of relative paths.',
hashInputsNode.span,
);
}
for (final inputNode in hashInputsNode.nodes) {
final input = _relativePathFromNode(inputNode, 'Hash input');
if (!seenHashInputs.add(input)) {
throw SourceSpanException(
'Duplicate hash input "$input".', inputNode.span);
}
hashInputs.add(input);
}
}
final compositeGroups = <CompositeGroup>[];
final groupNames = <String>{};
final outputNames = <String>{};
if (compositeGroupsNode != null) {
if (compositeGroupsNode is! YamlList) {
throw SourceSpanException(
'Composite groups must be a list.',
compositeGroupsNode.span,
);
}
for (final groupNode in compositeGroupsNode.nodes) {
final group = CompositeGroup.parse(groupNode);
if (!groupNames.add(group.name)) {
throw SourceSpanException(
'Duplicate composite group "${group.name}".',
groupNode.span,
);
}
for (final output in group.outputs) {
if (!outputNames.add(output)) {
throw SourceSpanException(
'Duplicate composite output "$output".',
groupNode.span,
);
}
}
compositeGroups.add(group);
}
}
final publicKeyValue = _stringFromNode(publicKeyNode, 'Public key');
return PrecompiledBinaries(
uriPrefix: _stringFromNode(urlPrefixNode, 'URL prefix'),
publicKey: _publicKeyFromHex(publicKeyValue, publicKeyNode.span),
workspaceRoot: workspaceRootNode == null
? '.'
: _relativePathFromNode(
workspaceRootNode,
'Workspace root',
allowParent: true,
),
hashInputs: List.unmodifiable(hashInputs),
buildRecipe: buildRecipeNode == null
? null
: PrecompiledBuildRecipe.parse(buildRecipeNode),
compositeGroups: List.unmodifiable(compositeGroups),
);
}
}
/// Cargokit options specified for Rust crate.
class CargokitCrateOptions {
CargokitCrateOptions({
this.cargo = const {},
this.precompiledBinaries,
});
final Map<BuildConfiguration, CargoBuildOptions> cargo;
final PrecompiledBinaries? precompiledBinaries;
static CargokitCrateOptions parse(YamlNode node) {
if (node is! YamlMap) {
throw SourceSpanException('Cargokit options must be a map', node.span);
}
final options = <BuildConfiguration, CargoBuildOptions>{};
PrecompiledBinaries? precompiledBinaries;
for (final entry in node.nodes.entries) {
if (entry
case MapEntry(
key: YamlScalar(value: 'cargo'),
value: YamlNode node,
)) {
if (node is! YamlMap) {
throw SourceSpanException('Cargo options must be a map', node.span);
}
for (final MapEntry(:YamlNode key, :value) in node.nodes.entries) {
if (key case YamlScalar(value: String name)) {
final configuration = BuildConfiguration.values
.firstWhereOrNull((element) => element.name == name);
if (configuration != null) {
options[configuration] = CargoBuildOptions.parse(value);
continue;
}
}
throw SourceSpanException(
'Unknown build configuration. Must be one of ${BuildConfiguration.values.map((e) => e.name)}.',
key.span);
}
} else if (entry.key case YamlScalar(value: 'precompiled_binaries')) {
precompiledBinaries = PrecompiledBinaries.parse(entry.value);
} else {
throw SourceSpanException(
'Unknown cargokit option type. Must be "cargo" or "precompiled_binaries".',
entry.key.span);
}
}
return CargokitCrateOptions(
cargo: options,
precompiledBinaries: precompiledBinaries,
);
}
static CargokitCrateOptions load({
required String manifestDir,
}) {
final uri = Uri.file(path.join(manifestDir, "cargokit.yaml"));
final file = File.fromUri(uri);
if (file.existsSync()) {
final contents = loadYamlNode(file.readAsStringSync(), sourceUrl: uri);
return parse(contents);
} else {
return CargokitCrateOptions();
}
}
}
enum PrecompiledBinariesMode {
auto,
disabled,
required,
}
class CargokitUserOptions {
// When Rustup is installed always build locally unless user opts into
// using precompiled binaries.
static bool defaultUsePrecompiledBinaries() {
return Rustup.executablePath() == null;
}
factory CargokitUserOptions({
bool? usePrecompiledBinaries,
PrecompiledBinariesMode? precompiledBinariesMode,
required bool verboseLogging,
}) {
if (usePrecompiledBinaries != null && precompiledBinariesMode != null) {
throw ArgumentError(
'usePrecompiledBinaries and precompiledBinariesMode are mutually exclusive.',
);
}
return CargokitUserOptions._values(
precompiledBinariesMode: precompiledBinariesMode ??
(usePrecompiledBinaries == null
? _defaultMode()
: _legacyMode(usePrecompiledBinaries)),
verboseLogging: verboseLogging,
);
}
CargokitUserOptions._defaults()
: precompiledBinariesMode = _defaultMode(),
verboseLogging = false;
CargokitUserOptions._values({
required this.precompiledBinariesMode,
required this.verboseLogging,
});
static PrecompiledBinariesMode _defaultMode() =>
defaultUsePrecompiledBinaries()
? PrecompiledBinariesMode.auto
: PrecompiledBinariesMode.disabled;
static PrecompiledBinariesMode _legacyMode(bool value) =>
value ? PrecompiledBinariesMode.auto : PrecompiledBinariesMode.disabled;
static CargokitUserOptions parse(YamlNode node) {
if (node is! YamlMap) {
throw SourceSpanException('Cargokit options must be a map', node.span);
}
var precompiledBinariesMode = _defaultMode();
bool verboseLogging = false;
SourceSpan? legacySettingSpan;
SourceSpan? modeSettingSpan;
for (final entry in node.nodes.entries) {
if (entry.key case YamlScalar(value: 'use_precompiled_binaries')) {
if (entry.value case YamlScalar(value: bool value)) {
precompiledBinariesMode = _legacyMode(value);
legacySettingSpan = entry.key.span;
continue;
}
throw SourceSpanException(
'Invalid value for "use_precompiled_binaries". Must be a boolean.',
entry.value.span);
} else if (entry.key
case YamlScalar(value: 'precompiled_binaries_mode')) {
final modeName = switch (entry.value) {
YamlScalar(value: String value) => value,
_ => throw SourceSpanException(
'Invalid value for "precompiled_binaries_mode". Must be one of '
'${PrecompiledBinariesMode.values.map((mode) => mode.name)}.',
entry.value.span,
),
};
final mode = PrecompiledBinariesMode.values
.firstWhereOrNull((element) => element.name == modeName);
if (mode == null) {
throw SourceSpanException(
'Invalid value for "precompiled_binaries_mode". Must be one of '
'${PrecompiledBinariesMode.values.map((mode) => mode.name)}.',
entry.value.span,
);
}
precompiledBinariesMode = mode;
modeSettingSpan = entry.key.span;
} else if (entry.key case YamlScalar(value: 'verbose_logging')) {
if (entry.value case YamlScalar(value: bool value)) {
verboseLogging = value;
continue;
}
throw SourceSpanException(
'Invalid value for "verbose_logging". Must be a boolean.',
entry.value.span);
} else {
throw SourceSpanException(
'Unknown cargokit option type. Must be "precompiled_binaries_mode", '
'"use_precompiled_binaries", or "verbose_logging".',
entry.key.span);
}
}
if (legacySettingSpan != null && modeSettingSpan != null) {
throw SourceSpanException(
'"precompiled_binaries_mode" and legacy '
'"use_precompiled_binaries" cannot both be set.',
modeSettingSpan,
);
}
return CargokitUserOptions(
precompiledBinariesMode: precompiledBinariesMode,
verboseLogging: verboseLogging,
);
}
static CargokitUserOptions load() {
String fileName = "cargokit_options.yaml";
var userProjectDir = Directory(Environment.rootProjectDir);
while (userProjectDir.parent.path != userProjectDir.path) {
final configFile = File(path.join(userProjectDir.path, fileName));
if (configFile.existsSync()) {
final contents = loadYamlNode(
configFile.readAsStringSync(),
sourceUrl: configFile.uri,
);
final res = parse(contents);
if (res.verboseLogging) {
_log.info('Found user options file at ${configFile.path}');
}
return res;
}
userProjectDir = userProjectDir.parent;
}
return CargokitUserOptions._defaults();
}
final PrecompiledBinariesMode precompiledBinariesMode;
bool get usePrecompiledBinaries =>
precompiledBinariesMode != PrecompiledBinariesMode.disabled;
bool get allowLocalBuild =>
precompiledBinariesMode != PrecompiledBinariesMode.required;
final bool verboseLogging;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,785 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:ed25519_edwards/ed25519_edwards.dart';
import 'package:path/path.dart' as path;
import 'options.dart';
/// The manifest authenticates release bytes. Apple code signing remains a
/// separate platform concern and is intentionally outside this boundary.
const precompiledGenerationManifestFileName = 'completion.json';
const precompiledGenerationManifestSignatureFileName = 'completion.json.sig';
const _manifestSchemaVersion = 2;
const _manifestScope = 'cargokit-precompiled-generation';
const precompiledAssetSignatureScheme = 'ed25519-cargokit-v2';
const _assetSignatureDomain = 'cargokit-precompiled-asset-signature';
final _sha256Pattern = RegExp(r'^[0-9a-f]{64}$');
final _commitPattern = RegExp(r'^[0-9a-f]{40}(?:[0-9a-f]{24})?$');
final _assetNamePattern = RegExp(r'^[A-Za-z0-9][A-Za-z0-9._-]*$');
class PrecompiledGenerationException implements Exception {
PrecompiledGenerationException(this.message);
final String message;
@override
String toString() => 'PrecompiledGenerationException: $message';
}
class PrecompiledAsset {
const PrecompiledAsset({
required this.name,
required this.length,
required this.sha256,
});
final String name;
final int length;
final String sha256;
}
class PrecompiledCompositeChecksum {
const PrecompiledCompositeChecksum({
required this.archive,
required this.checksum,
this.algorithm = 'sha256',
});
final String archive;
final String checksum;
final String algorithm;
}
class PrecompiledBuildIdentity {
PrecompiledBuildIdentity({
required this.rustToolchain,
required this.flutterVersion,
required this.xcodeVersion,
required Map<String, String> sdkVersions,
required Map<String, String> deploymentTargets,
required Iterable<String> rustTargets,
}) : sdkVersions = Map.unmodifiable(Map.from(sdkVersions)),
deploymentTargets = Map.unmodifiable(Map.from(deploymentTargets)),
rustTargets = List.unmodifiable(rustTargets);
factory PrecompiledBuildIdentity.fromRecipe(PrecompiledBuildRecipe recipe) {
return PrecompiledBuildIdentity(
rustToolchain: recipe.rustToolchain,
flutterVersion: recipe.flutterVersion,
xcodeVersion: recipe.xcodeVersion,
sdkVersions: recipe.sdkVersions,
deploymentTargets: recipe.deploymentTargets,
rustTargets: recipe.rustTargets,
);
}
final String rustToolchain;
final String flutterVersion;
final String xcodeVersion;
final Map<String, String> sdkVersions;
final Map<String, String> deploymentTargets;
final List<String> rustTargets;
Map<String, dynamic> _json() => {
'deployment_targets': _sortedStringMap(deploymentTargets),
'flutter_version': flutterVersion,
'rust_targets': [...rustTargets]..sort(),
'rust_toolchain': rustToolchain,
'sdk_versions': _sortedStringMap(sdkVersions),
'xcode_version': xcodeVersion,
};
bool matches(PrecompiledBuildIdentity other) =>
_canonicalJson(_json()) == _canonicalJson(other._json());
}
class PrecompiledGenerationProvenance {
PrecompiledGenerationProvenance({
required this.pinned,
required this.actual,
});
factory PrecompiledGenerationProvenance.fromRecipe(
PrecompiledBuildRecipe recipe) {
final identity = PrecompiledBuildIdentity.fromRecipe(recipe);
return PrecompiledGenerationProvenance(pinned: identity, actual: identity);
}
final PrecompiledBuildIdentity pinned;
final PrecompiledBuildIdentity actual;
Map<String, dynamic> _json() => {
'actual': actual._json(),
'pinned': pinned._json(),
};
}
class PrecompiledGenerationManifest {
PrecompiledGenerationManifest({
required this.generationHash,
required this.sourceCommit,
required this.provenance,
required Iterable<PrecompiledAsset> assets,
required Iterable<PrecompiledCompositeChecksum> compositeChecksums,
}) : assets = List.unmodifiable(assets),
compositeChecksums = List.unmodifiable(compositeChecksums) {
_validateCompositeMetadata(this.assets, this.compositeChecksums);
}
final String generationHash;
final String sourceCommit;
final PrecompiledGenerationProvenance provenance;
final List<PrecompiledAsset> assets;
final List<PrecompiledCompositeChecksum> compositeChecksums;
PrecompiledAsset? asset(String name) {
for (final item in assets) {
if (item.name == name) return item;
}
return null;
}
List<int> canonicalBytes() => utf8.encode(_canonicalJson(_json()));
Map<String, dynamic> _json() => {
'asset_signature_scheme': precompiledAssetSignatureScheme,
'assets': [
...assets.map((asset) => {
'length': asset.length,
'name': asset.name,
'sha256': asset.sha256,
})
]..sort((a, b) => (a['name'] as String).compareTo(b['name'] as String)),
'composite_checksums': [
...compositeChecksums.map((binding) => {
'algorithm': binding.algorithm,
'archive': binding.archive,
'checksum': binding.checksum,
})
]..sort((a, b) {
final archive =
(a['archive'] as String).compareTo(b['archive'] as String);
return archive != 0
? archive
: (a['checksum'] as String).compareTo(b['checksum'] as String);
}),
'generation_hash': generationHash,
'provenance': provenance._json(),
'schema_version': _manifestSchemaVersion,
'scope': _manifestScope,
'source_commit': sourceCommit,
};
Uint8List sign(PrivateKey privateKey) =>
ed25519Sign(privateKey, Uint8List.fromList(canonicalBytes()));
void verifySignature(PublicKey publicKey, List<int> signature) {
_verifySignature(publicKey, canonicalBytes(), signature, 'manifest');
}
void validateFor({
required String generationHash,
required PrecompiledBuildRecipe recipe,
required Set<String> expectedAssetNames,
}) {
if (this.generationHash != generationHash) {
throw PrecompiledGenerationException(
'Manifest generation hash does not match the current generation.');
}
final expected = PrecompiledBuildIdentity.fromRecipe(recipe);
if (!provenance.pinned.matches(expected) ||
!provenance.actual.matches(provenance.pinned)) {
throw PrecompiledGenerationException(
'Manifest provenance does not match the build recipe.');
}
final actualNames = assets.map((asset) => asset.name).toSet();
if (actualNames.length != assets.length ||
actualNames.length != expectedAssetNames.length ||
!actualNames.containsAll(expectedAssetNames) ||
!expectedAssetNames.containsAll(actualNames)) {
throw PrecompiledGenerationException(
'Manifest asset inventory is incomplete or unexpected.');
}
}
void verifyAsset({
required String name,
required List<int> bytes,
required List<int> signature,
required PublicKey publicKey,
}) {
final expected = asset(name);
if (expected == null) {
throw PrecompiledGenerationException(
'Asset "$name" is not in the manifest inventory.');
}
if (bytes.length != expected.length ||
sha256.convert(bytes).toString() != expected.sha256) {
throw PrecompiledGenerationException(
'Asset "$name" does not match its manifest digest or length.');
}
verifyAssetMetadataSignature(
name: name,
signature: signature,
publicKey: publicKey,
);
}
void verifyAssetMetadataSignature({
required String name,
required List<int> signature,
required PublicKey publicKey,
}) {
final expected = asset(name);
if (expected == null) {
throw PrecompiledGenerationException(
'Asset "$name" is not in the manifest inventory.');
}
verifyPrecompiledAssetMetadataSignature(
publicKey,
generationHash: generationHash,
name: expected.name,
length: expected.length,
sha256: expected.sha256,
signature: signature,
);
}
void validateCompositeChecksums(Map<String, List<int>> bytesByName) {
for (final binding in compositeChecksums) {
if (binding.algorithm != 'sha256') {
throw PrecompiledGenerationException('Unsupported checksum algorithm.');
}
final archive = bytesByName[binding.archive];
final checksum = bytesByName[binding.checksum];
if (archive == null || checksum == null) {
throw PrecompiledGenerationException(
'Composite checksum assets are incomplete.');
}
final expected = ascii.encode('${sha256.convert(archive)}\n');
if (checksum.length != expected.length ||
!List<int>.generate(
expected.length,
(index) => checksum[index] ^ expected[index],
).every((difference) => difference == 0)) {
throw PrecompiledGenerationException(
'Composite checksum does not match its archive.');
}
}
}
static PrecompiledGenerationManifest parse(List<int> bytes) {
final text = _decodeUtf8(bytes);
final value = _StrictJsonParser(text).parse();
final map = _object(value, 'manifest');
_requireFields(
map,
const {
'schema_version',
'scope',
'asset_signature_scheme',
'generation_hash',
'source_commit',
'provenance',
'assets',
'composite_checksums',
},
'manifest',
);
if (map['schema_version'] != _manifestSchemaVersion) {
throw PrecompiledGenerationException(
'Unsupported manifest schema version.');
}
if (map['scope'] != _manifestScope) {
throw PrecompiledGenerationException('Unsupported manifest scope.');
}
if (map['asset_signature_scheme'] != precompiledAssetSignatureScheme) {
throw PrecompiledGenerationException(
'Unsupported asset signature scheme.');
}
final generationHash = _string(map['generation_hash'], 'generation hash');
if (!_sha256Pattern.hasMatch(generationHash)) {
throw PrecompiledGenerationException('Malformed generation hash.');
}
final sourceCommit = _string(map['source_commit'], 'source commit');
if (!_commitPattern.hasMatch(sourceCommit)) {
throw PrecompiledGenerationException('Malformed source commit.');
}
final provenanceMap = _object(map['provenance'], 'provenance');
_requireFields(provenanceMap, const {'pinned', 'actual'}, 'provenance');
final provenance = PrecompiledGenerationProvenance(
pinned: _identity(provenanceMap['pinned'], 'pinned provenance'),
actual: _identity(provenanceMap['actual'], 'actual provenance'),
);
final assetsValue = map['assets'];
if (assetsValue is! List || assetsValue.isEmpty) {
throw PrecompiledGenerationException(
'Manifest assets must be a non-empty list.');
}
final assets = <PrecompiledAsset>[];
String? previousName;
final seenAssets = <String>{};
for (final value in assetsValue) {
final assetMap = _object(value, 'asset');
_requireFields(assetMap, const {'name', 'length', 'sha256'}, 'asset');
final name =
_safeName(_string(assetMap['name'], 'asset name'), 'asset name');
if (!seenAssets.add(name)) {
throw PrecompiledGenerationException('Duplicate asset "$name".');
}
if (previousName != null && name.compareTo(previousName) <= 0) {
throw PrecompiledGenerationException(
'Manifest assets must be sorted by name.');
}
previousName = name;
final length = _nonNegativeInt(assetMap['length'], 'asset length');
final hash = _string(assetMap['sha256'], 'asset hash');
if (!_sha256Pattern.hasMatch(hash)) {
throw PrecompiledGenerationException('Malformed asset hash.');
}
assets.add(PrecompiledAsset(name: name, length: length, sha256: hash));
}
final compositesValue = map['composite_checksums'];
if (compositesValue is! List) {
throw PrecompiledGenerationException(
'Composite checksum relationships must be a list.');
}
final composites = <PrecompiledCompositeChecksum>[];
final seenComposites = <String>{};
String? previousComposite;
for (final value in compositesValue) {
final bindingMap = _object(value, 'composite checksum');
_requireFields(bindingMap, const {'algorithm', 'archive', 'checksum'},
'composite checksum');
final algorithm = _string(bindingMap['algorithm'], 'checksum algorithm');
final archive = _safeName(
_string(bindingMap['archive'], 'archive asset'), 'archive asset');
final checksum = _safeName(
_string(bindingMap['checksum'], 'checksum asset'), 'checksum asset');
if (algorithm != 'sha256') {
throw PrecompiledGenerationException('Unsupported checksum algorithm.');
}
final identity = '$archive\u0000$checksum';
if (!seenComposites.add(identity)) {
throw PrecompiledGenerationException(
'Duplicate composite checksum relationship.');
}
if (previousComposite != null &&
identity.compareTo(previousComposite) <= 0) {
throw PrecompiledGenerationException(
'Composite checksum relationships must be sorted.');
}
previousComposite = identity;
if (!seenAssets.contains(archive) || !seenAssets.contains(checksum)) {
throw PrecompiledGenerationException(
'Composite checksum references an unknown asset.');
}
composites.add(PrecompiledCompositeChecksum(
archive: archive,
checksum: checksum,
algorithm: algorithm,
));
}
return PrecompiledGenerationManifest(
generationHash: generationHash,
sourceCommit: sourceCommit,
provenance: provenance,
assets: assets,
compositeChecksums: composites,
);
}
}
Uint8List signPrecompiledAsset(PrivateKey privateKey, List<int> bytes) =>
ed25519Sign(privateKey, Uint8List.fromList(bytes));
Uint8List precompiledAssetSignaturePayload({
required String generationHash,
required String name,
required Object length,
required String sha256,
}) {
if (!_sha256Pattern.hasMatch(generationHash) ||
!_sha256Pattern.hasMatch(sha256)) {
throw PrecompiledGenerationException(
'Asset signature metadata contains a malformed digest.');
}
_safeName(name, 'asset name');
final lengthValue = switch (length) {
int value => BigInt.from(value),
BigInt value => value,
_ => throw PrecompiledGenerationException(
'Asset signature metadata length is not an integer.'),
};
if (lengthValue < BigInt.zero ||
lengthValue > (BigInt.one << 64) - BigInt.one) {
throw PrecompiledGenerationException(
'Asset signature metadata length is outside u64.');
}
final nameBytes = utf8.encode(name);
if (nameBytes.length > 0xffffffff) {
throw PrecompiledGenerationException('Asset name is too long.');
}
final nameLength = ByteData(4)..setUint32(0, nameBytes.length, Endian.big);
final wordMask = BigInt.from(0xffffffff);
final assetLength = ByteData(8)
..setUint32(0, (lengthValue >> 32).toInt(), Endian.big)
..setUint32(4, (lengthValue & wordMask).toInt(), Endian.big);
return Uint8List.fromList([
...ascii.encode(_assetSignatureDomain),
0,
2,
..._decodeSha256(generationHash),
...nameLength.buffer.asUint8List(),
...nameBytes,
...assetLength.buffer.asUint8List(),
..._decodeSha256(sha256),
]);
}
Uint8List signPrecompiledAssetMetadata(
PrivateKey privateKey, {
required String generationHash,
required String name,
required int length,
required String sha256,
}) =>
ed25519Sign(
privateKey,
precompiledAssetSignaturePayload(
generationHash: generationHash,
name: name,
length: length,
sha256: sha256,
),
);
void verifyPrecompiledAssetMetadataSignature(
PublicKey publicKey, {
required String generationHash,
required String name,
required int length,
required String sha256,
required List<int> signature,
}) {
_verifySignature(
publicKey,
precompiledAssetSignaturePayload(
generationHash: generationHash,
name: name,
length: length,
sha256: sha256,
),
signature,
'asset "$name" metadata',
);
}
Uint8List ed25519Sign(PrivateKey privateKey, Uint8List bytes) =>
sign(privateKey, bytes);
void _verifySignature(
PublicKey publicKey, List<int> bytes, List<int> signature, String subject) {
if (signature.length != 64 ||
!verify(publicKey, Uint8List.fromList(bytes),
Uint8List.fromList(signature))) {
throw PrecompiledGenerationException(
'Invalid Ed25519 signature for $subject.');
}
}
Uint8List _decodeSha256(String value) => Uint8List.fromList([
for (var index = 0; index < value.length; index += 2)
int.parse(value.substring(index, index + 2), radix: 16),
]);
void _validateCompositeMetadata(
List<PrecompiledAsset> assets,
List<PrecompiledCompositeChecksum> composites,
) {
final byName = {for (final asset in assets) asset.name: asset};
final boundAssets = <String>{};
for (final binding in composites) {
if (binding.algorithm != 'sha256') {
throw PrecompiledGenerationException('Unsupported checksum algorithm.');
}
if (binding.archive == binding.checksum) {
throw PrecompiledGenerationException(
'Composite archive and checksum must be distinct assets.');
}
if (!boundAssets.add(binding.archive) ||
!boundAssets.add(binding.checksum)) {
throw PrecompiledGenerationException(
'An asset may appear in at most one composite binding.');
}
final archive = byName[binding.archive];
final checksum = byName[binding.checksum];
if (archive == null || checksum == null) {
throw PrecompiledGenerationException(
'Composite checksum references an unknown asset.');
}
final expectedBytes = ascii.encode('${archive.sha256}\n');
if (checksum.length != 65 ||
checksum.sha256 != sha256.convert(expectedBytes).toString()) {
throw PrecompiledGenerationException(
'Composite checksum metadata is inconsistent with its archive.');
}
}
}
PrecompiledBuildIdentity _identity(Object? value, String context) {
final map = _object(value, context);
_requireFields(
map,
const {
'rust_toolchain',
'flutter_version',
'xcode_version',
'sdk_versions',
'deployment_targets',
'rust_targets',
},
context);
return PrecompiledBuildIdentity(
rustToolchain: _string(map['rust_toolchain'], '$context Rust toolchain'),
flutterVersion: _string(map['flutter_version'], '$context Flutter version'),
xcodeVersion: _string(map['xcode_version'], '$context Xcode version'),
sdkVersions: _stringMap(map['sdk_versions'], '$context SDK versions'),
deploymentTargets:
_stringMap(map['deployment_targets'], '$context deployment targets'),
rustTargets:
_sortedUniqueStrings(map['rust_targets'], '$context Rust targets'),
);
}
Map<String, String> _stringMap(Object? value, String context) {
final map = _object(value, context);
final result = <String, String>{};
for (final entry in map.entries) {
if (entry.key.isEmpty) {
throw PrecompiledGenerationException('$context contains an empty key.');
}
result[entry.key] = _string(entry.value, '$context value');
}
return result;
}
List<String> _sortedUniqueStrings(Object? value, String context) {
if (value is! List || value.isEmpty) {
throw PrecompiledGenerationException('$context must be a non-empty list.');
}
final result = <String>[];
final seen = <String>{};
for (final item in value) {
final string = _string(item, '$context entry');
if (!seen.add(string)) {
throw PrecompiledGenerationException('Duplicate $context entry.');
}
result.add(string);
}
return result;
}
Map<String, dynamic> _object(Object? value, String context) {
if (value is Map<String, dynamic>) return value;
throw PrecompiledGenerationException('$context must be an object.');
}
void _requireFields(
Map<String, dynamic> map, Set<String> required, String context) {
if (map.keys.length != required.length ||
!map.keys.toSet().containsAll(required)) {
final unknown = map.keys.where((key) => !required.contains(key));
final missing = required.where((key) => !map.containsKey(key));
throw PrecompiledGenerationException(
'$context fields are invalid (unknown: ${unknown.join(', ')}, missing: ${missing.join(', ')}).');
}
}
String _string(Object? value, String context) {
if (value is String && value.isNotEmpty) return value;
throw PrecompiledGenerationException('$context must be a non-empty string.');
}
int _nonNegativeInt(Object? value, String context) {
if (value is int && value >= 0) return value;
throw PrecompiledGenerationException(
'$context must be a non-negative integer.');
}
String _safeName(String value, String context) {
if (value.contains('\\') ||
path.posix.isAbsolute(value) ||
path.windows.isAbsolute(value) ||
path.posix.normalize(value) != value ||
value.split('/').any((part) =>
part.isEmpty ||
part == '.' ||
part == '..' ||
!_assetNamePattern.hasMatch(part))) {
throw PrecompiledGenerationException('$context is unsafe.');
}
return value;
}
Map<String, String> _sortedStringMap(Map<String, String> map) {
final keys = map.keys.toList()..sort();
return {for (final key in keys) key: map[key]!};
}
String _canonicalJson(Object? value) {
if (value is Map<String, dynamic>) {
final keys = value.keys.toList()..sort();
return '{${keys.map((key) => '${jsonEncode(key)}:${_canonicalJson(value[key])}').join(',')}}';
}
if (value is List) return '[${value.map(_canonicalJson).join(',')}]';
return jsonEncode(value);
}
String _decodeUtf8(List<int> bytes) {
try {
return utf8.decode(bytes);
} on FormatException {
throw PrecompiledGenerationException('Manifest is not valid UTF-8.');
}
}
class _StrictJsonParser {
_StrictJsonParser(this.input);
final String input;
int index = 0;
Object? parse() {
_space();
final value = _value();
_space();
if (index != input.length) _fail('Trailing data.');
return value;
}
Object? _value() {
_space();
if (index >= input.length) _fail('Unexpected end of JSON.');
return switch (input.codeUnitAt(index)) {
0x7b => _objectValue(),
0x5b => _arrayValue(),
0x22 => _stringValue(),
0x74 => _literal('true', true),
0x66 => _literal('false', false),
0x6e => _literal('null', null),
_ => _numberValue(),
};
}
Map<String, dynamic> _objectValue() {
index++;
final result = <String, dynamic>{};
_space();
if (_take(0x7d)) return result;
while (true) {
_space();
if (index >= input.length || input.codeUnitAt(index) != 0x22) {
_fail('Object key must be a string.');
}
final key = _stringValue();
if (result.containsKey(key)) _fail('Duplicate object key "$key".');
_space();
if (!_take(0x3a)) _fail('Expected colon after object key.');
result[key] = _value();
_space();
if (_take(0x7d)) return result;
if (!_take(0x2c)) _fail('Expected comma between object fields.');
}
}
List<dynamic> _arrayValue() {
index++;
final result = <dynamic>[];
_space();
if (_take(0x5d)) return result;
while (true) {
result.add(_value());
_space();
if (_take(0x5d)) return result;
if (!_take(0x2c)) _fail('Expected comma between array values.');
}
}
String _stringValue() {
final start = index;
index++;
while (index < input.length) {
final code = input.codeUnitAt(index++);
if (code == 0x22) {
final raw = input.substring(start, index);
try {
final value = jsonDecode(raw);
if (value is String) return value;
} on FormatException {
_fail('Malformed string.');
}
_fail('Malformed string.');
}
if (code == 0x5c) {
if (index >= input.length) _fail('Malformed escape.');
final escaped = input.codeUnitAt(index++);
if (escaped == 0x75) {
if (index + 4 > input.length ||
!RegExp(r'^[0-9a-fA-F]{4}$')
.hasMatch(input.substring(index, index + 4))) {
_fail('Malformed unicode escape.');
}
index += 4;
} else if (!const [0x22, 0x5c, 0x2f, 0x62, 0x66, 0x6e, 0x72, 0x74]
.contains(escaped)) {
_fail('Malformed escape.');
}
} else if (code < 0x20) {
_fail('Unescaped control character.');
}
}
_fail('Unterminated string.');
}
Object? _literal(String literal, Object? value) {
if (!input.startsWith(literal, index)) _fail('Malformed literal.');
index += literal.length;
return value;
}
Object _numberValue() {
final match =
RegExp(r'-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?')
.matchAsPrefix(input.substring(index));
if (match == null) _fail('Malformed JSON value.');
index += match.end;
final text = match.group(0)!;
return text.contains('.') || text.contains('e') || text.contains('E')
? double.parse(text)
: int.parse(text);
}
bool _take(int code) {
if (index < input.length && input.codeUnitAt(index) == code) {
index++;
return true;
}
return false;
}
void _space() {
while (index < input.length &&
const [0x20, 0x09, 0x0a, 0x0d].contains(input.codeUnitAt(index))) {
index++;
}
}
Never _fail(String message) => throw PrecompiledGenerationException(message);
}
@@ -0,0 +1,142 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:path/path.dart' as path;
import 'util.dart';
String? Function()? testRustupExecutablePathOverride;
class _Toolchain {
_Toolchain(
this.name,
this.targets,
);
final String name;
final List<String> targets;
}
class Rustup {
List<String>? installedTargets(String toolchain) {
final targets = _installedTargets(toolchain);
return targets != null ? List.unmodifiable(targets) : null;
}
void installToolchain(String toolchain) {
log.info("Installing Rust toolchain: $toolchain");
runCommand("rustup", ['toolchain', 'install', toolchain]);
_installedToolchains
.add(_Toolchain(toolchain, _getInstalledTargets(toolchain)));
}
void installTarget(
String target, {
required String toolchain,
}) {
log.info("Installing Rust target: $target");
runCommand("rustup", [
'target',
'add',
'--toolchain',
toolchain,
target,
]);
_installedTargets(toolchain)?.add(target);
}
final List<_Toolchain> _installedToolchains;
Rustup() : _installedToolchains = _getInstalledToolchains();
List<String>? _installedTargets(String toolchain) => _installedToolchains
.firstWhereOrNull(
(e) => e.name == toolchain || e.name.startsWith('$toolchain-'))
?.targets;
static List<_Toolchain> _getInstalledToolchains() {
String extractToolchainName(String line) {
// ignore (default) after toolchain name
final parts = line.split(' ');
return parts[0];
}
final res = runCommand("rustup", ['toolchain', 'list']);
// To list all non-custom toolchains, we need to filter out lines that
// don't start with "stable", "beta", or "nightly".
Pattern nonCustom = RegExp(r"^(stable|beta|nightly)");
final lines = res.stdout
.toString()
.split('\n')
.where((e) => e.isNotEmpty && e.startsWith(nonCustom))
.map(extractToolchainName)
.toList(growable: true);
return lines
.map(
(name) => _Toolchain(
name,
_getInstalledTargets(name),
),
)
.toList(growable: true);
}
static List<String> _getInstalledTargets(String toolchain) {
final res = runCommand("rustup", [
'target',
'list',
'--toolchain',
toolchain,
'--installed',
]);
final lines = res.stdout
.toString()
.split('\n')
.where((e) => e.isNotEmpty)
.toList(growable: true);
return lines;
}
bool _didInstallRustSrcForNightly = false;
void installRustSrcForNightly() {
if (_didInstallRustSrcForNightly) {
return;
}
// Useful for -Z build-std
runCommand(
"rustup",
['component', 'add', 'rust-src', '--toolchain', 'nightly'],
);
_didInstallRustSrcForNightly = true;
}
static String? executablePath() {
if (testRustupExecutablePathOverride != null) {
return testRustupExecutablePathOverride!();
}
final envPath = Platform.environment['PATH'];
final envPathSeparator = Platform.isWindows ? ';' : ':';
final home = Platform.isWindows
? Platform.environment['USERPROFILE']
: Platform.environment['HOME'];
final paths = [
if (home != null) path.join(home, '.cargo', 'bin'),
if (envPath != null) ...envPath.split(envPathSeparator),
];
for (final p in paths) {
final rustup = Platform.isWindows ? 'rustup.exe' : 'rustup';
final rustupPath = path.join(p, rustup);
if (File(rustupPath).existsSync()) {
return rustupPath;
}
}
return null;
}
}
@@ -0,0 +1,140 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:collection/collection.dart';
import 'util.dart';
class Target {
Target({
required this.rust,
this.flutter,
this.android,
this.androidMinSdkVersion,
this.darwinPlatform,
this.darwinArch,
});
static final all = [
Target(
rust: 'armv7-linux-androideabi',
flutter: 'android-arm',
android: 'armeabi-v7a',
androidMinSdkVersion: 16,
),
Target(
rust: 'aarch64-linux-android',
flutter: 'android-arm64',
android: 'arm64-v8a',
androidMinSdkVersion: 21,
),
Target(
rust: 'i686-linux-android',
flutter: 'android-x86',
android: 'x86',
androidMinSdkVersion: 16,
),
Target(
rust: 'x86_64-linux-android',
flutter: 'android-x64',
android: 'x86_64',
androidMinSdkVersion: 21,
),
Target(
rust: 'x86_64-pc-windows-msvc',
flutter: 'windows-x64',
),
Target(
rust: 'x86_64-unknown-linux-gnu',
flutter: 'linux-x64',
),
Target(
rust: 'aarch64-unknown-linux-gnu',
flutter: 'linux-arm64',
),
Target(
rust: 'aarch64-apple-ios-sim',
darwinPlatform: 'iphonesimulator',
darwinArch: 'arm64',
),
Target(
rust: 'x86_64-apple-ios',
darwinPlatform: 'iphonesimulator',
darwinArch: 'x86_64',
),
Target(
rust: 'aarch64-apple-ios',
darwinPlatform: 'iphoneos',
darwinArch: 'arm64',
),
Target(
rust: 'x86_64-apple-darwin',
darwinPlatform: 'macosx',
darwinArch: 'x86_64',
),
Target(
rust: 'aarch64-apple-darwin',
darwinPlatform: 'macosx',
darwinArch: 'arm64',
),
];
static Target? forFlutterName(String flutterName) {
return all.firstWhereOrNull((element) => element.flutter == flutterName);
}
static Target? forDarwin({
required String platformName,
required String darwinAarch,
}) {
return all.firstWhereOrNull((element) => //
element.darwinPlatform == platformName &&
element.darwinArch == darwinAarch);
}
static Target? forRustTriple(String triple) {
return all.firstWhereOrNull((element) => element.rust == triple);
}
static List<Target> androidTargets() {
return all
.where((element) => element.android != null)
.toList(growable: false);
}
/// Returns buildable targets on current host platform ignoring Android targets.
static List<Target> buildableTargets() {
if (Platform.isLinux) {
// Right now we don't support cross-compiling on Linux. So we just return
// the host target.
final arch = runCommand('arch', []).stdout as String;
if (arch.trim() == 'aarch64') {
return [Target.forRustTriple('aarch64-unknown-linux-gnu')!];
} else {
return [Target.forRustTriple('x86_64-unknown-linux-gnu')!];
}
}
return all.where((target) {
if (Platform.isWindows) {
return target.rust.contains('-windows-');
} else if (Platform.isMacOS) {
return target.darwinPlatform != null;
}
return false;
}).toList(growable: false);
}
@override
String toString() {
return rust;
}
final String? flutter;
final String rust;
final String? android;
final int? androidMinSdkVersion;
final String? darwinPlatform;
final String? darwinArch;
}
@@ -0,0 +1,172 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:convert';
import 'dart:io';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'logging.dart';
import 'rustup.dart';
final log = Logger("process");
class CommandFailedException implements Exception {
final String executable;
final List<String> arguments;
final ProcessResult result;
CommandFailedException({
required this.executable,
required this.arguments,
required this.result,
});
@override
String toString() {
final stdout = result.stdout.toString().trim();
final stderr = result.stderr.toString().trim();
return [
"External Command: $executable ${arguments.map((e) => '"$e"').join(' ')}",
"Returned Exit Code: ${result.exitCode}",
kSeparator,
"STDOUT:",
if (stdout.isNotEmpty) stdout,
kSeparator,
"STDERR:",
if (stderr.isNotEmpty) stderr,
].join('\n');
}
}
class TestRunCommandArgs {
final String executable;
final List<String> arguments;
final String? workingDirectory;
final Map<String, String>? environment;
final bool includeParentEnvironment;
final bool runInShell;
final Encoding? stdoutEncoding;
final Encoding? stderrEncoding;
TestRunCommandArgs({
required this.executable,
required this.arguments,
this.workingDirectory,
this.environment,
this.includeParentEnvironment = true,
this.runInShell = false,
this.stdoutEncoding,
this.stderrEncoding,
});
}
class TestRunCommandResult {
TestRunCommandResult({
this.pid = 1,
this.exitCode = 0,
this.stdout = '',
this.stderr = '',
});
final int pid;
final int exitCode;
final String stdout;
final String stderr;
}
TestRunCommandResult Function(TestRunCommandArgs args)? testRunCommandOverride;
ProcessResult runCommand(
String executable,
List<String> arguments, {
String? workingDirectory,
Map<String, String>? environment,
bool includeParentEnvironment = true,
bool runInShell = false,
Encoding? stdoutEncoding = systemEncoding,
Encoding? stderrEncoding = systemEncoding,
}) {
if (testRunCommandOverride != null) {
final result = testRunCommandOverride!(TestRunCommandArgs(
executable: executable,
arguments: arguments,
workingDirectory: workingDirectory,
environment: environment,
includeParentEnvironment: includeParentEnvironment,
runInShell: runInShell,
stdoutEncoding: stdoutEncoding,
stderrEncoding: stderrEncoding,
));
return ProcessResult(
result.pid,
result.exitCode,
result.stdout,
result.stderr,
);
}
log.finer('Running command $executable ${arguments.join(' ')}');
final res = Process.runSync(
_resolveExecutable(executable),
arguments,
workingDirectory: workingDirectory,
environment: environment,
includeParentEnvironment: includeParentEnvironment,
runInShell: runInShell,
stderrEncoding: stderrEncoding,
stdoutEncoding: stdoutEncoding,
);
if (res.exitCode != 0) {
throw CommandFailedException(
executable: executable,
arguments: arguments,
result: res,
);
} else {
return res;
}
}
class RustupNotFoundException implements Exception {
@override
String toString() {
return [
' ',
'rustup not found in PATH.',
' ',
'Maybe you need to install Rust? It only takes a minute:',
' ',
if (Platform.isWindows) 'https://www.rust-lang.org/tools/install',
if (hasHomebrewRustInPath()) ...[
'\$ brew unlink rust # Unlink homebrew Rust from PATH',
],
if (!Platform.isWindows)
"\$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh",
' ',
].join('\n');
}
static bool hasHomebrewRustInPath() {
if (!Platform.isMacOS) {
return false;
}
final envPath = Platform.environment['PATH'] ?? '';
final paths = envPath.split(':');
return paths.any((p) {
return p.contains('homebrew') && File(path.join(p, 'rustc')).existsSync();
});
}
}
String _resolveExecutable(String executable) {
if (executable == 'rustup') {
final resolved = Rustup.executablePath();
if (resolved != null) {
return resolved;
}
throw RustupNotFoundException();
} else {
return executable;
}
}
@@ -0,0 +1,95 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:path/path.dart' as path;
import 'artifacts_provider.dart';
import 'cargo.dart';
import 'crate_hash.dart';
import 'options.dart';
import 'precompile_binaries.dart';
import 'precompiled_asset_store.dart';
import 'precompiled_generation.dart';
import 'target.dart';
class VerifyBinaries {
VerifyBinaries({
required this.manifestDir,
});
final String manifestDir;
Future<void> run() async {
final config = CargokitCrateOptions.load(manifestDir: manifestDir);
final precompiledBinaries = config.precompiledBinaries;
if (precompiledBinaries == null) {
stdout.writeln('Crate does not support precompiled binaries.');
return;
}
final recipe = precompiledBinaries.buildRecipe;
if (recipe == null) {
throw PrecompiledGenerationException(
'Configured precompiled binaries need a complete build recipe.');
}
final crateInfo = CrateInfo.load(manifestDir);
final expectedNames = <String>{};
for (final triple in recipe.rustTargets) {
final target = Target.forRustTriple(triple);
if (target == null) {
throw PrecompiledGenerationException(
'Build recipe contains an unsupported Rust target.');
}
for (final type in AritifactType.values) {
for (final artifact in getArtifactNames(
target: target,
libraryName: crateInfo.packageName,
remote: true,
aritifactType: type,
)) {
expectedNames.add(PrecompileBinaries.fileName(target, artifact));
}
}
}
final compositeChecksums = <String>{};
for (final group in precompiledBinaries.compositeGroups) {
for (final checksum
in group.outputs.where((output) => output.endsWith('.checksum'))) {
final archive =
checksum.substring(0, checksum.length - '.checksum'.length);
if (group.outputs.contains(archive)) {
expectedNames.add(archive);
expectedNames.add(checksum);
compositeChecksums.add('$archive\u0000$checksum');
}
}
}
final generationHash = CrateHash.compute(manifestDir);
final cacheRoot = Directory.systemTemp.createTempSync('cargokit-verify-');
final transport = PrecompiledAssetTransport();
try {
final store = PrecompiledAssetStore(
cacheRoot: path.join(cacheRoot.path, 'cache'),
uriPrefix: Uri.parse(precompiledBinaries.uriPrefix),
publicKey: precompiledBinaries.publicKey,
recipe: recipe,
expectedAssetNames: expectedNames,
expectedCompositeChecksums: compositeChecksums,
transport: transport,
);
final snapshot = await store.snapshot(
generationHash: generationHash,
requestedAssetNames: expectedNames,
);
if (snapshot == null) {
throw PrecompiledGenerationException(
'Completed v2 precompiled generation is unavailable.');
}
stdout.writeln('Verified generation $generationHash');
} finally {
transport.close();
if (cacheRoot.existsSync()) cacheRoot.deleteSync(recursive: true);
}
}
}
@@ -0,0 +1,469 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7"
url: "https://pub.dev"
source: hosted
version: "67.0.0"
adaptive_number:
dependency: transitive
description:
name: adaptive_number
sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
args:
dependency: "direct main"
description:
name: args
sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596
url: "https://pub.dev"
source: hosted
version: "2.4.2"
async:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
collection:
dependency: "direct main"
description:
name: collection
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
url: "https://pub.dev"
source: hosted
version: "1.18.0"
convert:
dependency: "direct main"
description:
name: convert
sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
coverage:
dependency: transitive
description:
name: coverage
sha256: e3493833ea012784c740e341952298f1cc77f1f01b1bbc3eb4eecf6984fb7f43
url: "https://pub.dev"
source: hosted
version: "1.11.1"
crypto:
dependency: "direct main"
description:
name: crypto
sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab
url: "https://pub.dev"
source: hosted
version: "3.0.3"
ed25519_edwards:
dependency: "direct main"
description:
name: ed25519_edwards
sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
file:
dependency: transitive
description:
name: file
sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d"
url: "https://pub.dev"
source: hosted
version: "6.1.4"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
github:
dependency: "direct main"
description:
name: github
sha256: "9966bc13bf612342e916b0a343e95e5f046c88f602a14476440e9b75d2295411"
url: "https://pub.dev"
source: hosted
version: "9.17.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
hex:
dependency: "direct main"
description:
name: hex
sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
http:
dependency: "direct main"
description:
name: http
sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
url: "https://pub.dev"
source: hosted
version: "4.0.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf
url: "https://pub.dev"
source: hosted
version: "0.7.1"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
lints:
dependency: "direct dev"
description:
name: lints
sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
logging:
dependency: "direct main"
description:
name: logging
sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.17"
meta:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
node_preamble:
dependency: transitive
description:
name: node_preamble
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: "direct main"
description:
name: path
sha256: "2ad4cddff7f5cc0e2d13069f2a3f7a73ca18f66abd6f5ecf215219cdb3638edb"
url: "https://pub.dev"
source: hosted
version: "1.8.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750
url: "https://pub.dev"
source: hosted
version: "5.4.0"
pool:
dependency: transitive
description:
name: pool
sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a"
url: "https://pub.dev"
source: hosted
version: "1.5.1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
shelf:
dependency: transitive
description:
name: shelf
sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4
url: "https://pub.dev"
source: hosted
version: "1.4.1"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
shelf_static:
dependency: transitive
description:
name: shelf_static
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
url: "https://pub.dev"
source: hosted
version: "1.1.3"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
url: "https://pub.dev"
source: hosted
version: "2.1.2"
source_maps:
dependency: transitive
description:
name: source_maps
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
url: "https://pub.dev"
source: hosted
version: "0.10.13"
source_span:
dependency: "direct main"
description:
name: source_span
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
url: "https://pub.dev"
source: hosted
version: "1.10.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test:
dependency: "direct dev"
description:
name: test
sha256: "301b213cd241ca982e9ba50266bd3f5bd1ea33f1455554c5abb85d1be0e2d87e"
url: "https://pub.dev"
source: hosted
version: "1.25.15"
test_api:
dependency: transitive
description:
name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://pub.dev"
source: hosted
version: "0.7.4"
test_core:
dependency: transitive
description:
name: test_core
sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa"
url: "https://pub.dev"
source: hosted
version: "0.6.8"
toml:
dependency: "direct main"
description:
name: toml
sha256: "157c5dca5160fced243f3ce984117f729c788bb5e475504f3dbcda881accee44"
url: "https://pub.dev"
source: hosted
version: "0.14.0"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version:
dependency: "direct main"
description:
name: version
sha256: "2307e23a45b43f96469eeab946208ed63293e8afca9c28cd8b5241ff31c55f55"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev"
source: hosted
version: "15.0.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83"
url: "https://pub.dev"
source: hosted
version: "0.1.6"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
yaml:
dependency: "direct main"
description:
name: yaml
sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5"
url: "https://pub.dev"
source: hosted
version: "3.1.2"
sdks:
dart: ">=3.5.0 <4.0.0"
@@ -0,0 +1,33 @@
# This is copied from Cargokit (which is the official way to use it currently)
# Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
name: build_tool
description: Cargokit build_tool. Facilitates the build of Rust crate during Flutter application build.
publish_to: none
version: 1.0.0
environment:
sdk: ">=3.0.0 <4.0.0"
# Add regular dependencies here.
dependencies:
# these are pinned on purpose because the bundle_tool_runner doesn't have
# pubspec.lock. See run_build_tool.sh
logging: 1.2.0
path: 1.8.0
version: 3.0.0
collection: 1.18.0
ed25519_edwards: 0.3.1
hex: 0.2.0
yaml: 3.1.2
source_span: 1.10.0
github: 9.17.0
args: 2.4.2
crypto: 3.0.3
convert: 3.1.1
http: 1.1.0
toml: 0.14.0
dev_dependencies:
lints: ^2.1.0
test: ^1.24.0
@@ -0,0 +1,123 @@
import 'dart:io';
import 'package:build_tool/src/android_environment.dart';
import 'package:build_tool/src/target.dart';
import 'package:build_tool/src/util.dart';
import 'package:path/path.dart' as path;
import 'package:test/test.dart';
void main() {
tearDown(() {
testRunCommandOverride = null;
});
test('arm64 and x64 android targets use 16 KB page size linker flags',
() async {
final sdkPath = _createFakeAndroidSdk();
final targets = [
Target.forRustTriple('aarch64-linux-android')!,
Target.forRustTriple('x86_64-linux-android')!,
];
for (final target in targets) {
final env = await AndroidEnvironment(
sdkPath: sdkPath,
ndkVersion: '26.3.11579264',
minSdkVersion: 21,
targetTempDir: '/tmp/fjs-cargokit-test',
target: target,
).buildEnvironment();
expect(
env['CARGO_ENCODED_RUSTFLAGS'],
contains('link-arg=-Wl,-z,max-page-size=16384'),
);
expect(
env['CARGO_ENCODED_RUSTFLAGS'],
contains('link-arg=-Wl,--hash-style=both'),
);
}
});
test('32-bit android targets keep the libgcc workaround without 16 KB flags',
() async {
final sdkPath = _createFakeAndroidSdk();
final target = Target.forRustTriple('armv7-linux-androideabi')!;
final env = await AndroidEnvironment(
sdkPath: sdkPath,
ndkVersion: '26.3.11579264',
minSdkVersion: 16,
targetTempDir: '/tmp/fjs-cargokit-test',
target: target,
).buildEnvironment();
expect(env['CARGO_ENCODED_RUSTFLAGS'], contains('libgcc_workaround'));
expect(
env['CARGO_ENCODED_RUSTFLAGS'],
isNot(contains('link-arg=-Wl,-z,max-page-size=16384')),
);
});
test('bindgen clang args use libclang-safe android paths', () async {
final sdkPath = _createFakeAndroidSdk(
sdkPathSegment: r'C:\Users\test\AppData\Local\Android\Sdk',
);
final target = Target.forRustTriple('aarch64-linux-android')!;
final env = await AndroidEnvironment(
sdkPath: sdkPath,
ndkVersion: '26.3.11579264',
minSdkVersion: 24,
targetTempDir: '/tmp/fjs-cargokit-test',
target: target,
).buildEnvironment();
final bindgenArgs = env['BINDGEN_EXTRA_CLANG_ARGS_aarch64-linux-android'];
expect(bindgenArgs, isNotNull);
expect(bindgenArgs, startsWith('--target=aarch64-linux-android24 '));
expect(bindgenArgs, isNot(contains(r'\')));
expect(
bindgenArgs,
contains('/C:/Users/test/AppData/Local/Android/Sdk/ndk/26.3.11579264/'),
);
});
}
String _createFakeAndroidSdk({String? sdkPathSegment}) {
final temp = Directory.systemTemp.createTempSync('fjs-cargokit-android-sdk-');
final sdk = sdkPathSegment == null
? temp
: Directory(path.join(temp.path, sdkPathSegment))
..createSync(recursive: true);
final toolchainBin = Directory(path.join(
sdk.path,
'ndk',
'26.3.11579264',
'toolchains',
'llvm',
'prebuilt',
Platform.isWindows
? 'windows-x86_64'
: Platform.isLinux
? 'linux-x86_64'
: 'darwin-x86_64',
'bin',
))
..createSync(recursive: true);
for (final tool in [
'llvm-ar',
'llvm-ranlib',
'clang',
'clang++',
if (Platform.isWindows) ...[
'llvm-ar.exe',
'llvm-ranlib.exe',
'clang.exe',
'clang++.exe',
],
]) {
File(path.join(toolchainBin.path, tool)).writeAsStringSync('');
}
return sdk.path;
}
@@ -0,0 +1,912 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:build_tool/src/artifacts_provider.dart';
import 'package:build_tool/src/builder.dart';
import 'package:build_tool/src/cargo.dart';
import 'package:build_tool/src/options.dart';
import 'package:build_tool/src/precompile_binaries.dart';
import 'package:build_tool/src/precompiled_asset_store.dart';
import 'package:build_tool/src/precompiled_generation.dart';
import 'package:build_tool/src/target.dart';
import 'package:crypto/crypto.dart';
import 'package:ed25519_edwards/ed25519_edwards.dart';
import 'package:http/http.dart';
import 'package:path/path.dart' as path;
import 'package:test/test.dart';
const _hash =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const _sourceCommit = '1111111111111111111111111111111111111111';
const _privateSeed = <int>[
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
31,
];
void main() {
late Directory temp;
late Target target;
late KeyPair keyPair;
late PrecompiledBuildRecipe recipe;
late BuildEnvironment environment;
setUp(() {
temp = Directory.systemTemp.createTempSync('artifacts-provider-test-');
target = Target.forRustTriple('x86_64-unknown-linux-gnu')!;
keyPair = newKeyPair();
recipe = PrecompiledBuildRecipe(
rustToolchain: '1.88.0',
flutterVersion: '3.32.8',
xcodeVersion: '16.4',
sdkVersions: const {'linux': '6.8'},
deploymentTargets: const {'linux': 'glibc-2.35'},
rustTargets: [target.rust],
);
environment = BuildEnvironment(
configuration: BuildConfiguration.release,
crateOptions: CargokitCrateOptions(
precompiledBinaries: PrecompiledBinaries(
uriPrefix: 'https://assets.example/precompiled/',
publicKey: keyPair.publicKey,
buildRecipe: recipe,
),
),
targetTempDir: temp.path,
manifestDir: temp.path,
crateInfo: CrateInfo(packageName: 'fjs'),
isAndroid: false,
);
});
tearDown(() => temp.deleteSync(recursive: true));
test('disabled mode uses local Rust and never contacts remote', () async {
var localCalls = 0;
var remoteCalls = 0;
final local = await _localArtifact(temp, target, 'local.a');
final provider = _provider(
environment,
PrecompiledBinariesMode.disabled,
httpGet: (_) async {
remoteCalls++;
return Response('', 500);
},
localBuilder: (target, _) async {
localCalls++;
return [local];
},
);
final result = await provider.getArtifacts([target]);
expect(result[target]!.single.path, local.path);
expect(localCalls, 1);
expect(remoteCalls, 0);
});
test('auto mode falls back locally only when completion manifest is absent',
() async {
var localCalls = 0;
var requested = <String>[];
final local = await _localArtifact(temp, target, 'local.a');
final provider = _provider(
environment,
PrecompiledBinariesMode.auto,
httpGet: (url) async {
requested.add(url.path);
return Response('', 404);
},
localBuilder: (target, _) async {
localCalls++;
return [local];
},
);
final result = await provider.getArtifacts([target]);
expect(result[target]!.single.path, local.path);
expect(localCalls, 1);
expect(requested, hasLength(1));
expect(requested.single, contains('completion.json'));
});
test('auto mode fails closed when completion is missing for existing cache',
() async {
final fixture = _fixture(recipe, target: target, keyPair: keyPair);
final installProvider = _provider(
environment,
PrecompiledBinariesMode.required,
httpGet: (url) async => fixture.responses[url.path] ?? Response('', 404),
);
await installProvider.getArtifacts([target]);
final cacheFile = File(path.join(
temp.path,
'precompiled',
'v2',
_hash,
'snapshots',
PrecompiledAssetStore.requestKey({fixture.assetName}),
fixture.assetName,
));
cacheFile.writeAsBytesSync([0, 0, 0]);
final requested = <String>[];
var localCalls = 0;
final orphanedProvider = _provider(
environment,
PrecompiledBinariesMode.auto,
httpGet: (url) async {
requested.add(url.path);
return Response('', 404);
},
localBuilder: (target, _) async {
localCalls++;
return const [];
},
);
await expectLater(
orphanedProvider.getArtifacts([target]),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(cacheFile.readAsBytesSync(), [0, 0, 0]);
expect(requested, [
'/precompiled/$_hash/completion.json',
]);
expect(localCalls, 0);
});
test('auto 404 fails closed for legacy staging previous and malformed v2',
() async {
final precompiledRoot = Directory(path.join(temp.path, 'precompiled'))
..createSync();
final states = <FileSystemEntity>[
Directory(path.join(precompiledRoot.path, '$_hash.staging-foreign')),
Directory(path.join(precompiledRoot.path, '$_hash.previous-foreign')),
File(path.join(precompiledRoot.path, 'v2')),
];
for (final state in states) {
if (state is Directory) {
state.createSync();
} else if (state is File) {
state.writeAsStringSync('malformed');
}
var localCalls = 0;
final provider = _provider(
environment,
PrecompiledBinariesMode.auto,
httpGet: (_) async => Response('', 404),
localBuilder: (_, __) async {
localCalls++;
return const [];
},
);
await expectLater(
provider.getArtifacts([target]),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(localCalls, 0);
if (state.existsSync()) state.deleteSync(recursive: true);
}
});
test(
'auto mode distinguishes unsupported precompilation from incomplete config',
() async {
var localCalls = 0;
var remoteCalls = 0;
final local = await _localArtifact(temp, target, 'local.a');
final noPrecompiled = _withPrecompiled(environment, null);
final unsupportedProvider = _provider(
noPrecompiled,
PrecompiledBinariesMode.auto,
httpGet: (_) async {
remoteCalls++;
return Response('', 500);
},
localBuilder: (target, _) async {
localCalls++;
return [local];
},
);
final localResult = await unsupportedProvider.getArtifacts([target]);
expect(localResult[target]!.single.path, local.path);
expect(localCalls, 1);
expect(remoteCalls, 0);
final incompleteConfig = _withPrecompiled(
environment,
PrecompiledBinaries(
uriPrefix: 'https://assets.example/precompiled/',
publicKey: keyPair.publicKey,
),
);
final incompleteProvider = _provider(
incompleteConfig,
PrecompiledBinariesMode.auto,
httpGet: (_) async {
remoteCalls++;
return Response('', 500);
},
localBuilder: (target, _) async {
localCalls++;
return [local];
},
);
await expectLater(
incompleteProvider.getArtifacts([target]),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(localCalls, 1);
expect(remoteCalls, 0);
final disabledProvider = _provider(
incompleteConfig,
PrecompiledBinariesMode.disabled,
httpGet: (_) async {
remoteCalls++;
return Response('', 500);
},
localBuilder: (target, _) async {
localCalls++;
return [local];
},
);
final disabledResult = await disabledProvider.getArtifacts([target]);
expect(disabledResult[target]!.single.path, local.path);
expect(localCalls, 2);
expect(remoteCalls, 0);
final requiredProvider = _provider(
incompleteConfig,
PrecompiledBinariesMode.required,
httpGet: (_) async {
remoteCalls++;
return Response('', 500);
},
localBuilder: (target, _) async {
localCalls++;
return [local];
},
);
await expectLater(
requiredProvider.getArtifacts([target]),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(localCalls, 2);
expect(remoteCalls, 0);
});
test('required mode fails on missing completion and never invokes local Rust',
() async {
var localCalls = 0;
final provider = _provider(
environment,
PrecompiledBinariesMode.required,
httpGet: (url) async => Response('', 404),
localBuilder: (target, _) async {
localCalls++;
return const [];
},
);
await expectLater(
provider.getArtifacts([target]),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(localCalls, 0);
});
test('fetches signed completion first and only exact requested target assets',
() async {
final fixture = _fixture(recipe, target: target, keyPair: keyPair);
final calls = <String>[];
final provider = _provider(
environment,
PrecompiledBinariesMode.required,
httpGet: (url) async {
calls.add(url.path);
return fixture.responses[url.path] ?? Response('', 404);
},
);
final result = await provider.getArtifacts([target]);
expect(result[target], hasLength(1));
expect(result[target]!.single.finalFileName, 'libfjs.so');
expect(calls.first, contains('completion.json'));
expect(calls[1], contains('completion.json.sig'));
expect(calls.where((call) => call.endsWith('.sig')).length, 2);
expect(calls, everyElement(isNot(contains('other-target'))));
});
test(
'rejects a requested target outside the signed recipe before asset requests',
() async {
final fixture = _fixture(recipe, target: target, keyPair: keyPair);
final unsupportedTarget =
Target.forRustTriple('aarch64-unknown-linux-gnu')!;
final calls = <String>[];
var localCalls = 0;
final provider = _provider(
environment,
PrecompiledBinariesMode.required,
httpGet: (url) async {
calls.add(url.path);
return fixture.responses[url.path] ?? Response('', 404);
},
localBuilder: (target, _) async {
localCalls++;
return const [];
},
);
await expectLater(
provider.getArtifacts([unsupportedTarget]),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(calls, isEmpty);
expect(localCalls, 0);
});
test('target-only request does not download unrelated composite assets',
() async {
final group = _compositeGroup(target);
final compositeEnvironment = _withPrecompiled(
environment,
PrecompiledBinaries(
uriPrefix: 'https://assets.example/precompiled/',
publicKey: keyPair.publicKey,
buildRecipe: recipe,
compositeGroups: [group],
),
);
final fixture = _fixture(
recipe,
target: target,
keyPair: keyPair,
includeComposite: true,
);
final calls = <String>[];
var localCalls = 0;
final provider = _provider(
compositeEnvironment,
PrecompiledBinariesMode.auto,
httpGet: (url) async {
calls.add(url.path);
return fixture.responses[url.path] ?? Response('', 404);
},
localBuilder: (target, _) async {
localCalls++;
return const [];
},
);
final result = await provider.getArtifacts([target]);
expect(result[target], hasLength(1));
expect(calls, isNot(contains(endsWith('/fjs.xcframework.zip'))));
expect(calls, isNot(contains(endsWith('/fjs.xcframework.zip.checksum'))));
expect(localCalls, 0);
});
test('revalidates immutable snapshot on every use and never repairs tamper',
() async {
final fixture = _fixture(recipe, target: target, keyPair: keyPair);
var assetRequests = 0;
final provider = _provider(
environment,
PrecompiledBinariesMode.required,
httpGet: (url) async {
if (url.path.endsWith('.so') || url.path.endsWith('.so.sig')) {
assetRequests++;
}
return fixture.responses[url.path] ?? Response('', 404);
},
);
await provider.getArtifacts([target]);
final cacheFile = File(path.join(
temp.path,
'precompiled',
'v2',
_hash,
'snapshots',
PrecompiledAssetStore.requestKey({fixture.assetName}),
fixture.assetName,
));
cacheFile.writeAsBytesSync([0, 0, 0]);
await expectLater(
provider.getArtifacts([target]),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(cacheFile.readAsBytesSync(), [0, 0, 0]);
expect(assetRequests, 2);
});
test(
'tampered cache plus invalid remote repair fails closed without local Rust',
() async {
final fixture = _fixture(recipe, target: target, keyPair: keyPair);
final initialProvider = _provider(
environment,
PrecompiledBinariesMode.required,
httpGet: (url) async => fixture.responses[url.path] ?? Response('', 404),
);
await initialProvider.getArtifacts([target]);
final cacheFile = File(path.join(
temp.path,
'precompiled',
'v2',
_hash,
'snapshots',
PrecompiledAssetStore.requestKey({fixture.assetName}),
fixture.assetName,
));
cacheFile.writeAsBytesSync([0, 0, 0]);
final invalidRemote = Map<String, Response>.from(fixture.responses);
final signaturePath = invalidRemote.keys
.firstWhere((key) => key.endsWith('${fixture.assetName}.sig'));
invalidRemote[signaturePath] = Response.bytes(Uint8List(64), 200);
var localCalls = 0;
final repairProvider = _provider(
environment,
PrecompiledBinariesMode.required,
httpGet: (url) async => invalidRemote[url.path] ?? Response('', 404),
localBuilder: (target, _) async {
localCalls++;
return const [];
},
);
await expectLater(
repairProvider.getArtifacts([target]),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(cacheFile.readAsBytesSync(), [0, 0, 0]);
expect(localCalls, 0);
});
test('tampered manifest anchor remains unchanged and never builds locally',
() async {
final fixture = _fixture(recipe, target: target, keyPair: keyPair);
await _provider(
environment,
PrecompiledBinariesMode.required,
httpGet: (url) async => fixture.responses[url.path] ?? Response('', 404),
).getArtifacts([target]);
final anchor = File(path.join(
temp.path,
'precompiled',
'v2',
_hash,
'anchor',
precompiledGenerationManifestFileName,
))
..writeAsBytesSync([0]);
var localCalls = 0;
await expectLater(
_provider(
environment,
PrecompiledBinariesMode.auto,
httpGet: (url) async =>
fixture.responses[url.path] ?? Response('', 404),
localBuilder: (_, __) async {
localCalls++;
return const [];
},
).getArtifacts([target]),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(anchor.readAsBytesSync(), [0]);
expect(localCalls, 0);
});
test(
'partial or signature-invalid remote generation fails closed without local fallback',
() async {
final fixture = _fixture(recipe, target: target, keyPair: keyPair);
var localCalls = 0;
final missingAssetResponses = Map<String, Response>.from(fixture.responses)
..removeWhere((key, _) => key.endsWith('.so'));
final provider = _provider(
environment,
PrecompiledBinariesMode.auto,
httpGet: (url) async =>
missingAssetResponses[url.path] ?? Response('', 404),
localBuilder: (target, _) async {
localCalls++;
return const [];
},
);
await expectLater(
provider.getArtifacts([target]),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(localCalls, 0);
final badSignatureResponses = Map<String, Response>.from(fixture.responses);
final signaturePath =
badSignatureResponses.keys.firstWhere((key) => key.endsWith('.so.sig'));
badSignatureResponses[signaturePath] = Response.bytes(Uint8List(64), 200);
final badProvider = _provider(
environment,
PrecompiledBinariesMode.required,
httpGet: (url) async =>
badSignatureResponses[url.path] ?? Response('', 404),
localBuilder: (target, _) async {
localCalls++;
return const [];
},
);
await expectLater(
badProvider.getArtifacts([target]),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(localCalls, 0);
});
test('download failure leaves anchor but no partial snapshot staging',
() async {
final fixture = _fixture(recipe, target: target, keyPair: keyPair);
final responses = Map<String, Response>.from(fixture.responses);
final assetPath = responses.keys.firstWhere((key) => key.endsWith('.so'));
responses[assetPath] = Response.bytes([1, 2, 3], 200);
final provider = _provider(
environment,
PrecompiledBinariesMode.required,
httpGet: (url) async => responses[url.path] ?? Response('', 404),
);
await expectLater(
provider.getArtifacts([target]),
throwsA(isA<PrecompiledGenerationException>()),
);
final generation =
Directory(path.join(temp.path, 'precompiled', 'v2', _hash));
expect(
Directory(path.join(generation.path, 'anchor')).existsSync(), isTrue);
expect(
Directory(path.join(generation.path, 'snapshots'))
.listSync(followLinks: false)
.where((entity) => path.basename(entity.path).contains('.staging-')),
isEmpty,
);
});
test('creates and closes a fresh transport for every remote lookup',
() async {
final fixture = _fixture(recipe, target: target, keyPair: keyPair);
final transports = <_TrackingTransport>[];
final provider = ArtifactProvider(
environment: environment,
userOptions: CargokitUserOptions(
precompiledBinariesMode: PrecompiledBinariesMode.required,
verboseLogging: false,
),
generationHash: (_) => _hash,
transportFactory: () {
final transport = _TrackingTransport(
(request) async => _streamedResponse(
fixture.responses[request.url.path] ?? Response('', 404),
request,
),
);
transports.add(transport);
return transport;
},
);
await provider.getArtifacts([target]);
await provider.getArtifacts([target]);
expect(transports, hasLength(2));
expect(transports.map((transport) => transport.closeCalls), [1, 1]);
});
test('closes its transport when a remote lookup fails', () async {
_TrackingTransport? transport;
final provider = ArtifactProvider(
environment: environment,
userOptions: CargokitUserOptions(
precompiledBinariesMode: PrecompiledBinariesMode.required,
verboseLogging: false,
),
generationHash: (_) => _hash,
transportFactory: () => transport = _TrackingTransport(
(_) async => StreamedResponse(
const Stream<List<int>>.empty(),
500,
),
),
);
await expectLater(
provider.getArtifacts([target]),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(transport!.closeCalls, 1);
});
test('validates the complete recipe before creating a transport', () async {
final invalidRecipe = PrecompiledBuildRecipe(
rustToolchain: recipe.rustToolchain,
flutterVersion: recipe.flutterVersion,
xcodeVersion: recipe.xcodeVersion,
sdkVersions: recipe.sdkVersions,
deploymentTargets: recipe.deploymentTargets,
rustTargets: [target.rust, 'unsupported-target'],
);
final invalidEnvironment = _withPrecompiled(
environment,
PrecompiledBinaries(
uriPrefix: 'https://assets.example/precompiled/',
publicKey: keyPair.publicKey,
buildRecipe: invalidRecipe,
),
);
var transports = 0;
final provider = ArtifactProvider(
environment: invalidEnvironment,
userOptions: CargokitUserOptions(
precompiledBinariesMode: PrecompiledBinariesMode.required,
verboseLogging: false,
),
generationHash: (_) => _hash,
transportFactory: () {
transports++;
return PrecompiledAssetTransport(
send: (_) async =>
StreamedResponse(const Stream<List<int>>.empty(), 500),
);
},
);
await expectLater(
provider.getArtifacts([target]),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(transports, 0);
});
}
class _TrackingTransport extends PrecompiledAssetTransport {
_TrackingTransport(PrecompiledHttpSend send) : super(send: send);
var closeCalls = 0;
@override
void close() {
closeCalls++;
super.close();
}
}
StreamedResponse _streamedResponse(Response response, BaseRequest request) {
return StreamedResponse(
Stream.value(response.bodyBytes),
response.statusCode,
contentLength: response.bodyBytes.length,
headers: response.headers,
reasonPhrase: response.reasonPhrase,
request: request,
isRedirect: response.isRedirect,
);
}
ArtifactProvider _provider(
BuildEnvironment environment,
PrecompiledBinariesMode mode, {
required ArtifactHttpGet httpGet,
ArtifactLocalBuilder? localBuilder,
}) {
return ArtifactProvider(
environment: environment,
userOptions: CargokitUserOptions(
precompiledBinariesMode: mode,
verboseLogging: false,
),
httpGet: httpGet,
generationHash: (_) => _hash,
localBuilder: localBuilder,
);
}
Future<Artifact> _localArtifact(
Directory temp, Target target, String name) async {
final file = File(path.join(temp.path, name))..writeAsBytesSync([1]);
return Artifact(path: file.path, finalFileName: name);
}
class _Fixture {
_Fixture(
{required this.responses,
required this.assetName,
required this.assetBytes});
final Map<String, Response> responses;
final String assetName;
final Uint8List assetBytes;
}
_Fixture _fixture(
PrecompiledBuildRecipe recipe, {
required Target target,
required KeyPair keyPair,
bool includeComposite = false,
}) {
final assetName = PrecompileBinaries.fileName(target, 'libfjs.so');
final assetBytes = Uint8List.fromList([5, 4, 3, 2]);
final staticAssetName = PrecompileBinaries.fileName(target, 'libfjs.a');
final staticAssetBytes = Uint8List.fromList([4, 3, 2, 1]);
final archiveBytes = Uint8List.fromList([8, 7, 6, 5]);
final checksumText = sha256Bytes(archiveBytes);
final checksumBytes = Uint8List.fromList(utf8.encode('$checksumText\n'));
final assets = <PrecompiledAsset>[
PrecompiledAsset(
name: assetName,
length: assetBytes.length,
sha256: sha256Bytes(assetBytes),
),
PrecompiledAsset(
name: staticAssetName,
length: staticAssetBytes.length,
sha256: sha256Bytes(staticAssetBytes),
),
if (includeComposite) ...[
PrecompiledAsset(
name: 'fjs.xcframework.zip',
length: archiveBytes.length,
sha256: sha256Bytes(archiveBytes),
),
PrecompiledAsset(
name: 'fjs.xcframework.zip.checksum',
length: checksumBytes.length,
sha256: sha256Bytes(checksumBytes),
),
],
]..sort((a, b) => a.name.compareTo(b.name));
final manifest = PrecompiledGenerationManifest(
generationHash: _hash,
sourceCommit: _sourceCommit,
provenance: PrecompiledGenerationProvenance.fromRecipe(recipe),
assets: assets,
compositeChecksums: includeComposite
? const [
PrecompiledCompositeChecksum(
archive: 'fjs.xcframework.zip',
checksum: 'fjs.xcframework.zip.checksum',
),
]
: const [],
);
final prefix = 'https://assets.example/precompiled/$_hash/';
return _Fixture(
assetName: assetName,
assetBytes: assetBytes,
responses: {
'/precompiled/$_hash/completion.json.sig':
Response.bytes(manifest.sign(keyPair.privateKey), 200),
'/precompiled/$_hash/completion.json':
Response.bytes(manifest.canonicalBytes(), 200),
'/precompiled/$_hash/$assetName.sig': Response.bytes(
signPrecompiledAssetMetadata(
keyPair.privateKey,
generationHash: _hash,
name: assetName,
length: assetBytes.length,
sha256: sha256Bytes(assetBytes),
),
200),
'/precompiled/$_hash/$assetName': Response.bytes(assetBytes, 200),
if (includeComposite) ...{
'/precompiled/$_hash/fjs.xcframework.zip.sig': Response.bytes(
signPrecompiledAssetMetadata(
keyPair.privateKey,
generationHash: _hash,
name: 'fjs.xcframework.zip',
length: archiveBytes.length,
sha256: sha256Bytes(archiveBytes),
),
200,
),
'/precompiled/$_hash/fjs.xcframework.zip':
Response.bytes(archiveBytes, 200),
'/precompiled/$_hash/fjs.xcframework.zip.checksum.sig': Response.bytes(
signPrecompiledAssetMetadata(
keyPair.privateKey,
generationHash: _hash,
name: 'fjs.xcframework.zip.checksum',
length: checksumBytes.length,
sha256: sha256Bytes(checksumBytes),
),
200,
),
'/precompiled/$_hash/fjs.xcframework.zip.checksum':
Response.bytes(checksumBytes, 200),
},
prefix: Response('', 404),
},
);
}
BuildEnvironment _withPrecompiled(
BuildEnvironment environment,
PrecompiledBinaries? precompiled,
) {
return BuildEnvironment(
configuration: environment.configuration,
crateOptions: CargokitCrateOptions(precompiledBinaries: precompiled),
targetTempDir: environment.targetTempDir,
manifestDir: environment.manifestDir,
crateInfo: environment.crateInfo,
isAndroid: environment.isAndroid,
androidSdkPath: environment.androidSdkPath,
androidNdkVersion: environment.androidNdkVersion,
androidMinSdkVersion: environment.androidMinSdkVersion,
javaHome: environment.javaHome,
);
}
CompositeGroup _compositeGroup(Target target) {
return CompositeGroup(
name: 'swiftpm',
host: CompositeHost.linux,
requiredTargets: [target.rust],
argv: const ['tool/build.sh'],
environment: const {},
timeout: const Duration(minutes: 1),
outputs: const [
'fjs.xcframework.zip',
'fjs.xcframework.zip.checksum',
],
);
}
String sha256Bytes(List<int> bytes) => sha256.convert(bytes).toString();
KeyPair newKeyPair() {
final private = newKeyFromSeed(Uint8List.fromList(_privateSeed));
return KeyPair(private, public(private));
}
@@ -0,0 +1,55 @@
import 'package:build_tool/src/builder.dart';
import 'package:build_tool/src/cargo.dart';
import 'package:build_tool/src/options.dart';
import 'package:build_tool/src/target.dart';
import 'package:build_tool/src/util.dart';
import 'package:test/test.dart';
void main() {
tearDown(() {
testRunCommandOverride = null;
});
test('cargo build uses rustc from the selected rustup toolchain', () async {
Map<String, String>? cargoEnvironment;
testRunCommandOverride = (args) {
if (args.executable == 'rustup' &&
args.arguments.length == 4 &&
args.arguments[0] == 'which' &&
args.arguments[1] == '--toolchain' &&
args.arguments[2] == 'stable' &&
args.arguments[3] == 'rustc') {
return TestRunCommandResult(stdout: '/rustup/stable/bin/rustc\n');
}
if (args.executable == 'rustup' &&
args.arguments.length >= 4 &&
args.arguments[0] == 'run' &&
args.arguments[1] == 'stable' &&
args.arguments[2] == 'cargo' &&
args.arguments[3] == 'build') {
cargoEnvironment = args.environment;
return TestRunCommandResult();
}
throw StateError(
'Unexpected command: ${args.executable} ${args.arguments}');
};
final builder = RustBuilder(
target: Target.forRustTriple('x86_64-pc-windows-msvc')!,
environment: BuildEnvironment(
configuration: BuildConfiguration.release,
crateOptions: CargokitCrateOptions(),
targetTempDir: '/tmp/fjs-cargokit-test',
manifestDir: '/tmp/fjs-cargokit-manifest',
crateInfo: CrateInfo(packageName: 'fjs'),
isAndroid: false,
),
);
await builder.build();
expect(cargoEnvironment?['RUSTC'], '/rustup/stable/bin/rustc');
});
}
@@ -0,0 +1,328 @@
import 'dart:convert';
import 'dart:io';
import 'package:build_tool/src/build_tool.dart';
import 'package:build_tool/src/builder.dart';
import 'package:build_tool/src/composite_artifacts.dart';
import 'package:build_tool/src/options.dart';
import 'package:build_tool/src/precompile_binaries.dart';
import 'package:build_tool/src/target.dart';
import 'package:path/path.dart' as path;
import 'package:test/test.dart';
void main() {
late Directory temp;
late Directory workspace;
late Directory targetRoot;
late CompositeGroup group;
setUp(() {
final systemTemp =
Directory(Directory.systemTemp.resolveSymbolicLinksSync());
temp = systemTemp.createTempSync('composite-artifacts-test.');
workspace = Directory(path.join(temp.path, 'workspace'))..createSync();
targetRoot = Directory(path.join(temp.path, 'targets'))..createSync();
File(path.join(workspace.path, 'assemble.sh'))
.writeAsStringSync('#!/bin/sh\n');
Directory(path.join(targetRoot.path, 'aarch64-apple-ios')).createSync();
group = CompositeGroup(
name: 'swiftpm',
host: CompositeHost.macos,
requiredTargets: const ['aarch64-apple-ios'],
argv: const ['assemble.sh', r'$literal'],
environment: const {'LC_ALL': 'C', 'PATH': '/usr/bin:/bin'},
timeout: const Duration(seconds: 3),
outputs: const ['bundle.zip', 'bundle.zip.checksum'],
);
});
tearDown(() => temp.deleteSync(recursive: true));
test('local generation command requires explicit paths and repeats targets',
() {
final command = BuildPrecompiledGenerationCommand();
final arguments = command.argParser.parse(const [
'--manifest-dir',
'/manifest',
'--output-dir',
'/output',
'--temp-dir',
'/temp',
'--target',
'aarch64-apple-ios',
'--target',
'aarch64-apple-darwin',
]);
expect(command.name, 'build-precompiled-generation');
expect(arguments['manifest-dir'], '/manifest');
expect(arguments['output-dir'], '/output');
expect(arguments['temp-dir'], '/temp');
expect(arguments['target'], [
'aarch64-apple-ios',
'aarch64-apple-darwin',
]);
});
test('composite uses controlled argv/env and atomically retains outputs',
() async {
CompositeProcessInvocation? invocation;
final outputRoot = path.join(temp.path, 'output');
final builder = CompositeArtifactBuilder(
workspaceRoot: workspace.path,
generationHash: 'generation-hash',
targetOutputRoot: targetRoot.path,
outputRoot: outputRoot,
currentHost: CompositeHost.macos,
processRunner: (value) async {
invocation = value;
final output = Directory(value.environment['CARGOKIT_OUTPUT_ROOT']!);
File(path.join(output.path, 'bundle.zip')).writeAsBytesSync([1, 2, 3]);
File(path.join(output.path, 'bundle.zip.checksum'))
.writeAsStringSync('checksum\n');
return const CompositeProcessResult(exitCode: 0);
},
);
final result = await builder.build(group);
expect(invocation!.executable, path.join(workspace.path, 'assemble.sh'));
expect(invocation!.arguments, [r'$literal']);
expect(invocation!.workingDirectory, workspace.path);
expect(invocation!.timeout, const Duration(seconds: 3));
expect(invocation!.environment, {
'LC_ALL': 'C',
'PATH': '/usr/bin:/bin',
'CARGOKIT_GENERATION_HASH': 'generation-hash',
'CARGOKIT_WORKSPACE_ROOT': workspace.path,
'CARGOKIT_TARGET_OUTPUT_ROOT': targetRoot.path,
'CARGOKIT_OUTPUT_ROOT': isNotEmpty,
'CARGOKIT_DART_EXECUTABLE': Platform.resolvedExecutable,
});
expect(File(result.outputs['bundle.zip']!).readAsBytesSync(), [1, 2, 3]);
expect(
Directory(outputRoot).listSync(recursive: true).where(
(entity) => path.basename(entity.path).startsWith('.swiftpm.')),
isEmpty,
);
});
test('composite rejects host/target, timeout, nonzero, and missing output',
() async {
Future<void> expectFailure(
String name,
CompositeProcessResult result,
) async {
final builder = CompositeArtifactBuilder(
workspaceRoot: workspace.path,
generationHash: 'generation-hash',
targetOutputRoot: targetRoot.path,
outputRoot: path.join(temp.path, name),
currentHost: CompositeHost.macos,
processRunner: (_) async => result,
);
await expectLater(
builder.build(group),
throwsA(isA<CompositeArtifactException>()),
);
}
final wrongHost = CompositeArtifactBuilder(
workspaceRoot: workspace.path,
generationHash: 'generation-hash',
targetOutputRoot: targetRoot.path,
outputRoot: path.join(temp.path, 'wrong-host'),
currentHost: CompositeHost.linux,
);
await expectLater(
wrongHost.build(group),
throwsA(isA<CompositeArtifactException>()),
);
Directory(path.join(targetRoot.path, 'aarch64-apple-ios'))
.deleteSync(recursive: true);
final missingTarget = CompositeArtifactBuilder(
workspaceRoot: workspace.path,
generationHash: 'generation-hash',
targetOutputRoot: targetRoot.path,
outputRoot: path.join(temp.path, 'missing-target'),
currentHost: CompositeHost.macos,
);
await expectLater(
missingTarget.build(group),
throwsA(isA<CompositeArtifactException>()),
);
Directory(path.join(targetRoot.path, 'aarch64-apple-ios')).createSync();
await expectFailure(
'timeout',
const CompositeProcessResult(exitCode: -1, timedOut: true),
);
await expectFailure(
'nonzero',
const CompositeProcessResult(exitCode: 9, stderr: 'failed'),
);
await expectFailure(
'missing-output',
const CompositeProcessResult(exitCode: 0),
);
});
test(
'local generation builds targets once, retains both forms, and is deterministic',
() async {
_writeCrate(workspace);
final buildCalls = <String, int>{};
var compositeCalls = 0;
Future<String> buildTarget(
Target target,
BuildEnvironment environment,
String toolchain,
) async {
expect(toolchain, '1.88.0');
buildCalls.update(target.rust, (value) => value + 1, ifAbsent: () => 1);
final output =
Directory(path.join(environment.targetTempDir, target.rust))
..createSync(recursive: true);
File(path.join(output.path, 'libfjs.a'))
.writeAsStringSync('static:${target.rust}');
File(path.join(output.path, 'libfjs.dylib'))
.writeAsStringSync('dynamic:${target.rust}');
return output.path;
}
Future<CompositeProcessResult> assemble(
CompositeProcessInvocation invocation) async {
compositeCalls++;
final output = Directory(invocation.environment['CARGOKIT_OUTPUT_ROOT']!);
File(path.join(output.path, 'bundle.zip')).writeAsStringSync('archive');
File(path.join(output.path, 'bundle.zip.checksum'))
.writeAsStringSync('checksum\n');
return const CompositeProcessResult(exitCode: 0);
}
final output = path.join(temp.path, 'generation');
final generator = LocalPrecompiledGeneration(
manifestDir: workspace.path,
outputDir: output,
tempDir: path.join(temp.path, 'build'),
targetTriples: const [
'aarch64-apple-ios',
'aarch64-apple-darwin',
],
targetBuilder: buildTarget,
currentHost: CompositeHost.macos,
compositeProcessRunner: assemble,
);
final firstFragment = await generator.run();
final firstBytes = firstFragment.readAsBytesSync();
expect(buildCalls, {
'aarch64-apple-ios': 1,
'aarch64-apple-darwin': 1,
});
expect(compositeCalls, 1);
final secondFragment = await generator.run();
expect(buildCalls, {
'aarch64-apple-ios': 2,
'aarch64-apple-darwin': 2,
});
expect(compositeCalls, 2);
expect(secondFragment.readAsBytesSync(), firstBytes);
for (final target in buildCalls.keys) {
expect(
File(path.join(output, 'targets', target, '${target}_libfjs.a'))
.existsSync(),
isTrue,
);
expect(
File(path.join(output, 'targets', target, '${target}_libfjs.dylib'))
.existsSync(),
isTrue,
);
}
final fragment =
jsonDecode(utf8.decode(firstBytes)) as Map<String, dynamic>;
expect(fragment['schema_version'], 1);
expect(fragment['scope'], 'cargokit-local-precompiled-generation');
expect(fragment['generation_hash'], matches(RegExp(r'^[0-9a-f]{64}$')));
expect((fragment['recipe'] as Map)['rust_toolchain'], '1.88.0');
expect(fragment['assets'], hasLength(6));
expect(fragment.containsKey('signature'), isFalse);
});
test('local generation rejects duplicate, unknown, and unpinned targets',
() async {
_writeCrate(workspace);
Future<void> rejects(List<String> targets, String suffix) async {
final generator = LocalPrecompiledGeneration(
manifestDir: workspace.path,
outputDir: path.join(temp.path, 'invalid-$suffix'),
tempDir: path.join(temp.path, 'invalid-build-$suffix'),
targetTriples: targets,
targetBuilder: (_, __, ___) => throw StateError('must not build'),
currentHost: CompositeHost.macos,
);
await expectLater(
generator.run(),
throwsA(isA<ArgumentError>()),
);
}
await rejects(
const ['aarch64-apple-ios', 'aarch64-apple-ios'],
'duplicate',
);
await rejects(const ['invented-target'], 'unknown');
await rejects(const ['x86_64-apple-ios'], 'unpinned');
await rejects(const ['aarch64-apple-ios'], 'incomplete-composite');
});
}
void _writeCrate(Directory workspace) {
File(path.join(workspace.path, 'Cargo.toml')).writeAsStringSync('''
[package]
name = "fjs"
version = "1.0.0"
''');
Directory(path.join(workspace.path, 'src')).createSync();
File(path.join(workspace.path, 'src/lib.rs'))
.writeAsStringSync('pub fn fjs() {}\n');
File(path.join(workspace.path, 'cargokit.yaml')).writeAsStringSync('''
precompiled_binaries:
url_prefix: https://example.com/precompiled_
public_key: a4c3433798eb2c36edf2b94dbb4dd899d57496ca373a8982d8a792410b7f6445
workspace_root: .
build_recipe:
rust_toolchain: '1.88.0'
flutter_version: '3.32.8'
xcode_version: '16.4'
sdk_versions:
iphoneos: '18.5'
macosx: '15.5'
deployment_targets:
ios: '12.0'
macos: '10.14'
rust_targets:
- aarch64-apple-ios
- aarch64-apple-darwin
composite_groups:
- name: swiftpm
host: macos
required_targets:
- aarch64-apple-ios
- aarch64-apple-darwin
argv:
- assemble.sh
- --retained
environment:
LC_ALL: C
PATH: /usr/bin:/bin
timeout_seconds: 3
outputs:
- bundle.zip
- bundle.zip.checksum
''');
}
@@ -0,0 +1,308 @@
import 'dart:io';
import 'package:build_tool/src/crate_hash.dart';
import 'package:path/path.dart' as path;
import 'package:test/test.dart';
const _publicKey =
'a4c3433798eb2c36edf2b94dbb4dd899d57496ca373a8982d8a792410b7f6445';
void main() {
late Directory temporaryDirectory;
late Directory workspace;
late Directory manifest;
setUp(() {
final resolvedSystemTemp =
Directory(Directory.systemTemp.resolveSymbolicLinksSync());
temporaryDirectory = resolvedSystemTemp.createTempSync('crate_hash_test.');
workspace = Directory(path.join(temporaryDirectory.path, 'workspace'))
..createSync();
manifest = Directory(path.join(workspace.path, 'crate'))..createSync();
_write(manifest, 'Cargo.toml', '[package]\nname = "test_crate"\n');
_write(manifest, 'src/lib.rs', 'pub fn answer() -> i32 { 42 }\n');
_writeConfig(manifest);
});
tearDown(() {
temporaryDirectory.deleteSync(recursive: true);
});
test('existing Rust input names and contents affect the hash', () {
final original = CrateHash.compute(manifest.path);
_write(manifest, 'src/lib.rs', 'pub fn answer() -> i32 { 43 }\n');
final contentChanged = CrateHash.compute(manifest.path);
expect(contentChanged, isNot(original));
final source = File(path.join(manifest.path, 'src/lib.rs'));
source.renameSync(path.join(manifest.path, 'src/renamed.rs'));
final pathChanged = CrateHash.compute(manifest.path);
expect(pathChanged, isNot(contentChanged));
});
const textProducerPaths = [
'producer/source.rs',
'producer/Cargo.toml',
'producer/config.yaml',
'producer/config.yml',
'producer/source.dart',
'producer/build.sh',
'producer/Plugin.swift',
'producer/data.json',
'producer/README.md',
'producer/Cargo.lock',
];
for (final producerPath in textProducerPaths) {
test('normalizes Git newlines in $producerPath', () {
_write(workspace, producerPath, 'first line\nsecond line\n');
_writeConfig(manifest, hashInputs: [producerPath]);
final lfHash = CrateHash.compute(manifest.path);
_write(workspace, producerPath, 'first line\r\nsecond line\r\n');
expect(CrateHash.compute(manifest.path), lfHash);
});
}
test('meaningful text changes affect the canonical hash', () {
const producerPath = 'producer/source.dart';
_write(workspace, producerPath, 'const answer = 42;\n');
_writeConfig(manifest, hashInputs: const [producerPath]);
final original = CrateHash.compute(manifest.path);
_write(workspace, producerPath, 'const answer = 43;\r\n');
expect(CrateHash.compute(manifest.path), isNot(original));
});
test('binary inputs preserve CRLF and invalid UTF-8 bytes', () {
const producerPath = 'producer/artifact.bin';
final producer = File(path.join(workspace.path, producerPath));
producer.parent.createSync(recursive: true);
producer.writeAsBytesSync([0xff, 0x0d, 0x0a]);
_writeConfig(manifest, hashInputs: const [producerPath]);
final crlfHash = CrateHash.compute(manifest.path);
producer.writeAsBytesSync([0xff, 0x0a]);
expect(CrateHash.compute(manifest.path), isNot(crlfHash));
});
test('declared files and directories are hashed recursively', () {
_write(workspace, 'producer.txt', 'producer-v1');
_write(workspace, 'tool/nested/build.sh', 'build-v1');
_writeConfig(
manifest,
hashInputs: const ['producer.txt', 'tool'],
);
final original = CrateHash.compute(manifest.path);
_write(workspace, 'producer.txt', 'producer-v2');
final fileChanged = CrateHash.compute(manifest.path);
expect(fileChanged, isNot(original));
_write(workspace, 'tool/nested/build.sh', 'build-v2');
final nestedFileChanged = CrateHash.compute(manifest.path);
expect(nestedFileChanged, isNot(fileChanged));
File(path.join(workspace.path, 'tool/nested/build.sh')).renameSync(
path.join(workspace.path, 'tool/nested/assemble.sh'),
);
final nestedPathChanged = CrateHash.compute(manifest.path);
expect(nestedPathChanged, isNot(nestedFileChanged));
});
test('adding empty nested declared directories affects the hash', () {
Directory(path.join(workspace.path, 'tool')).createSync();
_writeConfig(manifest, hashInputs: const ['tool']);
final original = CrateHash.compute(manifest.path);
Directory(path.join(workspace.path, 'tool/nested/empty'))
.createSync(recursive: true);
expect(CrateHash.compute(manifest.path), isNot(original));
});
test('removing empty nested declared directories affects the hash', () {
final nested = Directory(path.join(workspace.path, 'tool/nested/empty'))
..createSync(recursive: true);
_writeConfig(manifest, hashInputs: const ['tool']);
final original = CrateHash.compute(manifest.path);
nested.parent.deleteSync(recursive: true);
expect(CrateHash.compute(manifest.path), isNot(original));
});
test('renaming empty nested declared directories affects the hash', () {
final empty = Directory(path.join(workspace.path, 'tool/nested/empty'))
..createSync(recursive: true);
_writeConfig(manifest, hashInputs: const ['tool']);
final original = CrateHash.compute(manifest.path);
empty.renameSync(path.join(workspace.path, 'tool/nested/renamed'));
expect(CrateHash.compute(manifest.path), isNot(original));
});
test('undeclared workspace files do not affect the hash', () {
_write(workspace, 'producer.txt', 'producer');
_writeConfig(manifest, hashInputs: const ['producer.txt']);
final original = CrateHash.compute(manifest.path);
_write(workspace, 'unrelated.txt', 'first');
expect(CrateHash.compute(manifest.path), original);
_write(workspace, 'unrelated.txt', 'second');
expect(CrateHash.compute(manifest.path), original);
});
test('pinned build recipe changes affect the hash', () {
_writeConfig(manifest, rustToolchain: '1.88.0');
final original = CrateHash.compute(manifest.path);
_writeConfig(manifest, rustToolchain: '1.89.0');
expect(CrateHash.compute(manifest.path), isNot(original));
});
test('temp storage compatibility does not create persistent markers', () {
final cachePath = path.join(temporaryDirectory.path, 'cache');
final source = File(path.join(manifest.path, 'src/lib.rs'));
final original = CrateHash.compute(manifest.path, tempStorage: cachePath);
expect(CrateHash.compute(manifest.path, tempStorage: cachePath), original);
source.writeAsStringSync('pub fn answer() -> i32 { 43 }\n');
expect(CrateHash.compute(manifest.path, tempStorage: cachePath),
isNot(original));
expect(FileSystemEntity.typeSync(cachePath), FileSystemEntityType.notFound);
});
test('missing declared inputs fail closed', () {
_writeConfig(manifest, hashInputs: const ['missing.txt']);
expect(
() => CrateHash.compute(manifest.path),
throwsA(isA<FileSystemException>()),
);
});
test('declared symlinks fail closed', () {
_write(workspace, 'producer.txt', 'producer');
Link(path.join(workspace.path, 'producer-link'))
.createSync(path.join(workspace.path, 'producer.txt'));
_writeConfig(manifest, hashInputs: const ['producer-link']);
expect(
() => CrateHash.compute(manifest.path),
throwsA(isA<FileSystemException>()),
);
},
skip: Platform.isWindows
? 'Creating symlinks requires privileges.'
: false);
test('workspace root must contain the crate manifest', () {
_writeConfig(manifest, workspaceRoot: '../other');
expect(
() => CrateHash.compute(manifest.path),
throwsA(isA<FileSystemException>()),
);
});
test('intermediate workspace root symlinks fail closed', () {
final realWorkspace =
Directory(path.join(temporaryDirectory.path, 'real/workspace'))
..createSync(recursive: true);
final realManifest = Directory(path.join(realWorkspace.path, 'crate'))
..createSync();
_write(realManifest, 'Cargo.toml', '[package]\nname = "linked_crate"\n');
_write(realManifest, 'src/lib.rs', 'pub fn linked() {}\n');
Link(path.join(temporaryDirectory.path, 'workspace-link'))
.createSync(path.join(temporaryDirectory.path, 'real'));
_writeConfig(
realManifest,
workspaceRoot: '../../../workspace-link/workspace',
);
expect(
() => CrateHash.compute(realManifest.path),
throwsA(isA<FileSystemException>()),
);
},
skip: Platform.isWindows
? 'Creating symlinks requires privileges.'
: false);
test('intermediate manifest symlinks fail closed', () {
final realWorkspace =
Directory(path.join(temporaryDirectory.path, 'real/workspace'))
..createSync(recursive: true);
final realManifest = Directory(path.join(realWorkspace.path, 'crate'))
..createSync();
_write(realManifest, 'Cargo.toml', '[package]\nname = "linked_crate"\n');
_write(realManifest, 'src/lib.rs', 'pub fn linked() {}\n');
_writeConfig(realManifest, workspaceRoot: '../..');
Link(path.join(temporaryDirectory.path, 'workspace-link'))
.createSync(realWorkspace.path);
final linkedManifest =
Directory(path.join(temporaryDirectory.path, 'workspace-link/crate'));
expect(
() => CrateHash.compute(linkedManifest.path),
throwsA(isA<FileSystemException>()),
);
},
skip: Platform.isWindows
? 'Creating symlinks requires privileges.'
: false);
}
void _write(Directory root, String relativePath, String contents) {
final file = File(path.join(root.path, relativePath));
file.parent.createSync(recursive: true);
file.writeAsStringSync(contents);
}
void _writeConfig(
Directory manifest, {
String workspaceRoot = '..',
List<String> hashInputs = const [],
String? rustToolchain,
}) {
final recipe = rustToolchain == null
? ''
: '''
build_recipe:
rust_toolchain: '$rustToolchain'
flutter_version: '3.32.8'
xcode_version: '16.4'
sdk_versions:
iphoneos: '18.5'
deployment_targets:
ios: '12.0'
rust_targets:
- aarch64-apple-ios
''';
final inputs = hashInputs.isEmpty
? ''
: 'hash_inputs:\n${hashInputs.map((input) => ' - $input').join('\n')}\n';
_write(manifest, 'cargokit.yaml', '''
precompiled_binaries:
url_prefix: https://example.com/precompiled_
public_key: $_publicKey
workspace_root: $workspaceRoot
${_indent(inputs, 2)}${_indent(recipe, 2)}''');
}
String _indent(String value, int spaces) {
if (value.isEmpty) {
return value;
}
final prefix = ' ' * spaces;
return value
.split('\n')
.map((line) => line.isEmpty ? line : '$prefix$line')
.join('\n');
}
@@ -0,0 +1,73 @@
import 'package:build_tool/src/darwin_environment.dart';
import 'package:build_tool/src/target.dart';
import 'package:build_tool/src/util.dart';
import 'package:test/test.dart';
void main() {
setUp(() {
testRunCommandOverride = (args) {
if (args.executable != 'xcrun') {
throw StateError('Unexpected executable: ${args.executable}');
}
final sdk = args.arguments[1];
if (args.arguments[2] == '--show-sdk-path') {
return TestRunCommandResult(stdout: '/sdk/$sdk\n');
}
if (args.arguments[2] == '--find') {
final tool = args.arguments[3];
return TestRunCommandResult(stdout: '/toolchain/$sdk/$tool\n');
}
throw StateError('Unexpected xcrun args: ${args.arguments}');
};
});
tearDown(() {
testRunCommandOverride = null;
});
test('builds simulator environment for arm64 ios sim target', () {
final env = DarwinEnvironment(
target: Target.forRustTriple('aarch64-apple-ios-sim')!,
deploymentTarget: '12.0',
).buildEnvironment();
expect(env['SDKROOT'], '/sdk/iphonesimulator');
expect(env['CC_aarch64-apple-ios-sim'], '/toolchain/iphonesimulator/clang');
expect(
env['CFLAGS_aarch64-apple-ios-sim'],
'--target=arm64-apple-ios-simulator -isysroot /sdk/iphonesimulator -mios-simulator-version-min=12.0',
);
expect(
env['BINDGEN_EXTRA_CLANG_ARGS_aarch64-apple-ios-sim'],
'--target=arm64-apple-ios-simulator -isysroot /sdk/iphonesimulator -mios-simulator-version-min=12.0',
);
});
test('builds simulator environment for x86_64 ios target', () {
final env = DarwinEnvironment(
target: Target.forRustTriple('x86_64-apple-ios')!,
deploymentTarget: '12.0',
).buildEnvironment();
expect(env['SDKROOT'], '/sdk/iphonesimulator');
expect(
env['CFLAGS_x86_64-apple-ios'],
'--target=x86_64-apple-ios-simulator -isysroot /sdk/iphonesimulator -mios-simulator-version-min=12.0',
);
});
test('builds device environment for arm64 ios target', () {
final env = DarwinEnvironment(
target: Target.forRustTriple('aarch64-apple-ios')!,
deploymentTarget: '12.0',
).buildEnvironment();
expect(env['SDKROOT'], '/sdk/iphoneos');
expect(
env['CFLAGS_aarch64-apple-ios'],
'--target=arm64-apple-ios -isysroot /sdk/iphoneos -miphoneos-version-min=12.0',
);
});
}
@@ -0,0 +1,425 @@
import 'package:build_tool/src/builder.dart';
import 'package:build_tool/src/options.dart';
import 'package:build_tool/src/rustup.dart';
import 'package:build_tool/src/util.dart';
import 'package:hex/hex.dart';
import 'package:test/test.dart';
import 'package:yaml/yaml.dart';
void main() {
tearDown(() {
testRunCommandOverride = null;
testRustupExecutablePathOverride = null;
});
test('parse cargo build options', () {
final yaml = '''
toolchain: nightly
extra_flags:
- -Z
- build-std=panic_abort,std
''';
final options = CargoBuildOptions.parse(loadYamlNode(yaml));
expect(options.toolchain, Toolchain.nightly);
expect(options.flags, ['-Z', 'build-std=panic_abort,std']);
});
test('parse precompiled binaries config', () {
final yaml = '''
url_prefix: https://example.com/precompiled_
public_key: a4c3433798eb2c36edf2b94dbb4dd899d57496ca373a8982d8a792410b7f6445
''';
final config = PrecompiledBinaries.parse(loadYamlNode(yaml));
expect(config.uriPrefix, 'https://example.com/precompiled_');
expect(
config.publicKey.bytes,
HEX.decode(
'a4c3433798eb2c36edf2b94dbb4dd899d57496ca373a8982d8a792410b7f6445',
),
);
});
test('parse complete precompiled generation config', () {
final yaml = '''
url_prefix: https://example.com/precompiled_
public_key: a4c3433798eb2c36edf2b94dbb4dd899d57496ca373a8982d8a792410b7f6445
workspace_root: ..
hash_inputs:
- pubspec.yaml
- tool
build_recipe:
rust_toolchain: '1.88.0'
flutter_version: '3.32.8'
xcode_version: '16.4'
sdk_versions:
iphoneos: '18.5'
macosx: '15.5'
deployment_targets:
ios: '12.0'
macos: '10.14'
rust_targets:
- aarch64-apple-ios
- aarch64-apple-darwin
composite_groups:
- name: swiftpm
host: macos
required_targets:
- aarch64-apple-ios
- aarch64-apple-darwin
argv:
- tool/assemble_fjs_xcframework.sh
- --release
environment:
LC_ALL: C
timeout_seconds: 900
outputs:
- fjs.xcframework.zip
- fjs.xcframework.zip.checksum
''';
final config = PrecompiledBinaries.parse(loadYamlNode(yaml));
expect(config.workspaceRoot, '..');
expect(config.hashInputs, ['pubspec.yaml', 'tool']);
expect(config.buildRecipe?.rustToolchain, '1.88.0');
expect(config.buildRecipe?.flutterVersion, '3.32.8');
expect(config.buildRecipe?.xcodeVersion, '16.4');
expect(config.buildRecipe?.sdkVersions,
{'iphoneos': '18.5', 'macosx': '15.5'});
expect(config.buildRecipe?.deploymentTargets,
{'ios': '12.0', 'macos': '10.14'});
expect(config.buildRecipe?.rustTargets,
['aarch64-apple-ios', 'aarch64-apple-darwin']);
expect(config.compositeGroups, hasLength(1));
final group = config.compositeGroups.single;
expect(group.name, 'swiftpm');
expect(group.host, CompositeHost.macos);
expect(
group.requiredTargets, ['aarch64-apple-ios', 'aarch64-apple-darwin']);
expect(group.argv, ['tool/assemble_fjs_xcframework.sh', '--release']);
expect(group.environment, {'LC_ALL': 'C'});
expect(group.timeout, const Duration(seconds: 900));
expect(
group.outputs, ['fjs.xcframework.zip', 'fjs.xcframework.zip.checksum']);
});
group('precompiled generation config rejects', () {
const publicKey =
'a4c3433798eb2c36edf2b94dbb4dd899d57496ca373a8982d8a792410b7f6445';
String config(String body) => '''
url_prefix: https://example.com/precompiled_
public_key: $publicKey
$body
''';
void expectInvalid(String body) {
expect(
() => PrecompiledBinaries.parse(loadYamlNode(config(body))),
throwsA(isA<SourceSpanException>()),
);
}
test('unknown fields', () {
expectInvalid('unexpected: true');
expectInvalid('''
build_recipe:
rust_toolchain: 1.88.0
flutter_version: 3.32.8
xcode_version: 16.4
sdk_versions: {}
deployment_targets: {}
rust_targets:
- aarch64-apple-ios
unexpected: true
''');
expectInvalid('''
composite_groups:
- name: swiftpm
host: macos
required_targets:
- aarch64-apple-ios
argv:
- tool/build.sh
timeout_seconds: 60
outputs:
- output.zip
unexpected: true
''');
});
test('absolute and escaping paths', () {
expectInvalid('workspace_root: /tmp/workspace');
expectInvalid('''
hash_inputs:
- ../outside
''');
expectInvalid('''
composite_groups:
- name: swiftpm
host: macos
required_targets:
- aarch64-apple-ios
argv:
- /tmp/build.sh
timeout_seconds: 60
outputs:
- output.zip
''');
expectInvalid('''
composite_groups:
- name: swiftpm
host: macos
required_targets:
- aarch64-apple-ios
argv:
- ../build.sh
timeout_seconds: 60
outputs:
- output.zip
''');
});
test('invalid hosts and targets', () {
expectInvalid('''
composite_groups:
- name: swiftpm
host: darwin
required_targets:
- aarch64-apple-ios
argv:
- tool/build.sh
timeout_seconds: 60
outputs:
- output.zip
''');
expectInvalid('''
composite_groups:
- name: swiftpm
host: macos
required_targets:
- invented-rust-target
argv:
- tool/build.sh
timeout_seconds: 60
outputs:
- output.zip
''');
});
test('empty commands and outputs and invalid timeouts', () {
expectInvalid('''
composite_groups:
- name: swiftpm
host: macos
required_targets:
- aarch64-apple-ios
argv: []
timeout_seconds: 60
outputs:
- output.zip
''');
expectInvalid('''
composite_groups:
- name: swiftpm
host: macos
required_targets:
- aarch64-apple-ios
argv:
- tool/build.sh
timeout_seconds: 60
outputs: []
''');
expectInvalid('''
composite_groups:
- name: swiftpm
host: macos
required_targets:
- aarch64-apple-ios
argv:
- tool/build.sh
timeout_seconds: 0
outputs:
- output.zip
''');
});
test('unsafe and duplicate output names', () {
expectInvalid('''
composite_groups:
- name: swiftpm
host: macos
required_targets:
- aarch64-apple-ios
argv:
- tool/build.sh
timeout_seconds: 60
outputs:
- ../output.zip
''');
expectInvalid('''
composite_groups:
- name: first
host: macos
required_targets:
- aarch64-apple-ios
argv:
- tool/build.sh
timeout_seconds: 60
outputs:
- output.zip
- name: second
host: macos
required_targets:
- aarch64-apple-darwin
argv:
- tool/build.sh
timeout_seconds: 60
outputs:
- output.zip
''');
});
test('duplicate groups and targets', () {
expectInvalid('''
composite_groups:
- name: swiftpm
host: macos
required_targets:
- aarch64-apple-ios
argv:
- tool/build.sh
timeout_seconds: 60
outputs:
- first.zip
- name: swiftpm
host: macos
required_targets:
- aarch64-apple-darwin
argv:
- tool/build.sh
timeout_seconds: 60
outputs:
- second.zip
''');
expectInvalid('''
composite_groups:
- name: swiftpm
host: macos
required_targets:
- aarch64-apple-ios
- aarch64-apple-ios
argv:
- tool/build.sh
timeout_seconds: 60
outputs:
- output.zip
''');
});
});
test('parse crate options with cargo and precompiled binaries', () {
final yaml = '''
cargo:
debug:
toolchain: nightly
extra_flags:
- -Z
- build-std=panic_abort,std
release:
toolchain: beta
precompiled_binaries:
url_prefix: https://example.com/precompiled_
public_key: a4c3433798eb2c36edf2b94dbb4dd899d57496ca373a8982d8a792410b7f6445
''';
final options = CargokitCrateOptions.parse(loadYamlNode(yaml));
expect(
options.cargo[BuildConfiguration.debug]!.toolchain, Toolchain.nightly);
expect(
options.cargo[BuildConfiguration.debug]!.flags,
['-Z', 'build-std=panic_abort,std'],
);
expect(
options.cargo[BuildConfiguration.release]!.toolchain, Toolchain.beta);
expect(options.precompiledBinaries?.uriPrefix,
'https://example.com/precompiled_');
});
test('default user options build locally when rustup is available', () {
testRustupExecutablePathOverride = () => '/usr/bin/rustup';
final options = CargokitUserOptions.parse(loadYamlNode('{}'));
expect(options.usePrecompiledBinaries, false);
expect(options.verboseLogging, false);
});
test('default user options use precompiled binaries when rustup is missing',
() {
testRustupExecutablePathOverride = () => null;
final options = CargokitUserOptions.parse(loadYamlNode('{}'));
expect(options.usePrecompiledBinaries, true);
expect(options.verboseLogging, false);
});
test('explicit user options override default precompiled binary behavior',
() {
final options = CargokitUserOptions.parse(loadYamlNode('''
use_precompiled_binaries: true
verbose_logging: true
'''));
expect(options.usePrecompiledBinaries, true);
expect(options.verboseLogging, true);
});
test('parse precompiled binary modes', () {
for (final mode in PrecompiledBinariesMode.values) {
final options = CargokitUserOptions.parse(loadYamlNode('''
precompiled_binaries_mode: ${mode.name}
'''));
expect(options.precompiledBinariesMode, mode);
expect(options.usePrecompiledBinaries,
mode != PrecompiledBinariesMode.disabled);
expect(options.allowLocalBuild, mode != PrecompiledBinariesMode.required);
}
});
test('legacy precompiled boolean maps to auto or disabled', () {
final enabled = CargokitUserOptions.parse(
loadYamlNode('use_precompiled_binaries: true'));
final disabled = CargokitUserOptions.parse(
loadYamlNode('use_precompiled_binaries: false'));
expect(enabled.precompiledBinariesMode, PrecompiledBinariesMode.auto);
expect(disabled.precompiledBinariesMode, PrecompiledBinariesMode.disabled);
});
test('precompiled mode rejects legacy setting conflict', () {
expect(
() => CargokitUserOptions.parse(loadYamlNode('''
precompiled_binaries_mode: required
use_precompiled_binaries: true
''')),
throwsA(isA<SourceSpanException>()),
);
});
test('precompiled mode rejects unknown values', () {
expect(
() => CargokitUserOptions.parse(
loadYamlNode('precompiled_binaries_mode: sometimes')),
throwsA(isA<SourceSpanException>()),
);
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,550 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:build_tool/src/options.dart';
import 'package:build_tool/src/precompiled_generation.dart';
import 'package:crypto/crypto.dart';
import 'package:ed25519_edwards/ed25519_edwards.dart';
import 'package:test/test.dart';
const _generationHash =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const _archiveHash =
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
const _checksumHash =
'5c6449da335badb355097ca2a7898509bce8feebfe31dedce47798825fdd9a11';
const _sourceCommit = '1111111111111111111111111111111111111111';
void main() {
late KeyPair keyPair;
late PrecompiledBuildRecipe recipe;
setUp(() {
keyPair = KeyPair(
newKeyFromSeed(Uint8List.fromList(List<int>.generate(32, (i) => i))),
public(
newKeyFromSeed(Uint8List.fromList(List<int>.generate(32, (i) => i)))),
);
recipe = PrecompiledBuildRecipe(
rustToolchain: '1.88.0',
flutterVersion: '3.32.8',
xcodeVersion: '16.4',
sdkVersions: const {'macosx': '15.5', 'iphoneos': '18.5'},
deploymentTargets: const {'macos': '10.14', 'ios': '12.0'},
rustTargets: const [
'x86_64-apple-darwin',
'aarch64-apple-darwin',
],
);
});
test('canonical encoding is deterministic and recursively sorted', () {
final first = _manifest(recipe, reverseAssets: true);
final second = _manifest(recipe);
expect(first.canonicalBytes(), second.canonicalBytes());
expect(
utf8.decode(first.canonicalBytes()),
'{"asset_signature_scheme":"ed25519-cargokit-v2",'
'"assets":[{"length":3,"name":"aarch64-apple-darwin_libfjs.a",'
'"sha256":"$_checksumHash"},{"length":4,"name":"fjs.xcframework.zip",'
'"sha256":"$_archiveHash"},{"length":65,'
'"name":"fjs.xcframework.zip.checksum","sha256":"$_checksumHash"}],'
'"composite_checksums":[{"algorithm":"sha256",'
'"archive":"fjs.xcframework.zip",'
'"checksum":"fjs.xcframework.zip.checksum"}],'
'"generation_hash":"$_generationHash","provenance":{"actual":'
'{"deployment_targets":{"ios":"12.0","macos":"10.14"},'
'"flutter_version":"3.32.8","rust_targets":'
'["aarch64-apple-darwin","x86_64-apple-darwin"],'
'"rust_toolchain":"1.88.0","sdk_versions":'
'{"iphoneos":"18.5","macosx":"15.5"},"xcode_version":"16.4"},'
'"pinned":{"deployment_targets":{"ios":"12.0","macos":"10.14"},'
'"flutter_version":"3.32.8","rust_targets":'
'["aarch64-apple-darwin","x86_64-apple-darwin"],'
'"rust_toolchain":"1.88.0","sdk_versions":'
'{"iphoneos":"18.5","macosx":"15.5"},"xcode_version":"16.4"}},'
'"schema_version":2,"scope":"cargokit-precompiled-generation",'
'"source_commit":"$_sourceCommit"}',
);
});
test('canonical manifest round trips through strict parsing', () {
final manifest = _manifest(recipe);
final parsed =
PrecompiledGenerationManifest.parse(manifest.canonicalBytes());
expect(parsed.canonicalBytes(), manifest.canonicalBytes());
expect(parsed.asset('fjs.xcframework.zip')?.length, 4);
});
group('strict manifest parsing rejects', () {
test('unknown, missing, and duplicate fields', () {
final valid = jsonDecode(utf8.decode(_manifest(recipe).canonicalBytes()))
as Map<String, dynamic>;
expect(
() => _parse({...valid, 'unexpected': true}),
throwsA(isA<PrecompiledGenerationException>()),
);
final missing = {...valid}..remove('scope');
expect(
() => _parse(missing),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(
() => PrecompiledGenerationManifest.parse(utf8.encode(
'{"schema_version":2,"schema_version":2}',
)),
throwsA(isA<PrecompiledGenerationException>()),
);
});
test('v1, unsupported schema, scheme, and scope', () {
final valid = jsonDecode(utf8.decode(_manifest(recipe).canonicalBytes()))
as Map<String, dynamic>;
expect(
() => _parse({...valid, 'schema_version': 1}),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(
() => _parse({...valid, 'schema_version': 3}),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(
() => _parse({...valid, 'asset_signature_scheme': 'ed25519'}),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(
() => _parse({...valid, 'scope': 'github-release'}),
throwsA(isA<PrecompiledGenerationException>()),
);
});
test('unsafe paths, malformed hashes, duplicates, and unsorted inventory',
() {
final valid = jsonDecode(utf8.decode(_manifest(recipe).canonicalBytes()))
as Map<String, dynamic>;
final assets = (valid['assets'] as List).cast<Map<String, dynamic>>();
expect(
() => _parse({...valid, 'generation_hash': 'not-a-sha256'}),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(
() => _parse({
...valid,
'assets': [
{...assets.first, 'name': '../escape'},
...assets.skip(1),
],
}),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(
() => _parse({
...valid,
'assets': [assets.first, assets.first]
}),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(
() => _parse({...valid, 'assets': assets.reversed.toList()}),
throwsA(isA<PrecompiledGenerationException>()),
);
});
});
test('validation rejects incomplete inventory and provenance mismatch', () {
final manifest = _manifest(recipe);
expect(
() => manifest.validateFor(
generationHash: _generationHash,
recipe: recipe,
expectedAssetNames: const {
'aarch64-apple-darwin_libfjs.a',
'x86_64-apple-darwin_libfjs.a',
'fjs.xcframework.zip',
'fjs.xcframework.zip.checksum',
},
),
throwsA(isA<PrecompiledGenerationException>()),
);
final differentRecipe = PrecompiledBuildRecipe(
rustToolchain: '1.89.0',
flutterVersion: recipe.flutterVersion,
xcodeVersion: recipe.xcodeVersion,
sdkVersions: recipe.sdkVersions,
deploymentTargets: recipe.deploymentTargets,
rustTargets: recipe.rustTargets,
);
expect(
() => manifest.validateFor(
generationHash: _generationHash,
recipe: differentRecipe,
expectedAssetNames: manifest.assets.map((asset) => asset.name).toSet(),
),
throwsA(isA<PrecompiledGenerationException>()),
);
});
test('asset signature payload has the exact v2 metadata encoding', () {
final payload = precompiledAssetSignaturePayload(
generationHash: _generationHash,
name: 'asset.bin',
length: 3,
sha256:
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
);
expect(
_hex(payload),
'636172676f6b69742d707265636f6d70696c65642d61737365742d7369676e6174757265'
'0002'
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
'0000000961737365742e62696e'
'0000000000000003'
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
);
});
test('asset signature payload encodes the full unsigned u64 range', () {
String encodedLength(BigInt length) {
final payload = precompiledAssetSignaturePayload(
generationHash: _generationHash,
name: 'asset.bin',
length: length,
sha256:
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
);
final offset = ascii
.encode(
'cargokit-precompiled-asset-signature',
)
.length +
2 +
32 +
4 +
ascii.encode('asset.bin').length;
return _hex(payload.sublist(offset, offset + 8));
}
expect(encodedLength(BigInt.one << 63), '8000000000000000');
expect(encodedLength((BigInt.one << 64) - BigInt.one), 'ffffffffffffffff');
});
test('asset signatures reject protocol substitution and u64 overflow', () {
final payload = precompiledAssetSignaturePayload(
generationHash: _generationHash,
name: 'asset.bin',
length: 3,
sha256:
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
);
final signature = sign(keyPair.privateKey, payload);
final substituted = Uint8List.fromList(payload)..[payload.indexOf(2)] = 3;
expect(verify(keyPair.publicKey, payload, signature), isTrue);
expect(verify(keyPair.publicKey, substituted, signature), isFalse);
expect(
() => precompiledAssetSignaturePayload(
generationHash: _generationHash,
name: 'asset.bin',
length: BigInt.one << 64,
sha256:
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
),
throwsA(isA<PrecompiledGenerationException>()),
);
});
test('Ed25519 binds asset generation, name, length, and digest metadata', () {
final assetBytes = Uint8List.fromList([9, 8, 7]);
final assetHash = sha256.convert(assetBytes).toString();
final manifest = PrecompiledGenerationManifest(
generationHash: _generationHash,
sourceCommit: _sourceCommit,
provenance: PrecompiledGenerationProvenance.fromRecipe(recipe),
assets: [
PrecompiledAsset(name: 'asset.bin', length: 3, sha256: assetHash),
],
compositeChecksums: const [],
);
final manifestSignature = manifest.sign(keyPair.privateKey);
final assetSignature = signPrecompiledAssetMetadata(
keyPair.privateKey,
generationHash: _generationHash,
name: 'asset.bin',
length: assetBytes.length,
sha256: assetHash,
);
manifest.verifySignature(keyPair.publicKey, manifestSignature);
manifest.verifyAsset(
name: 'asset.bin',
bytes: assetBytes,
signature: assetSignature,
publicKey: keyPair.publicKey,
);
expect(
() => manifest.verifyAsset(
name: 'asset.bin',
bytes: Uint8List.fromList([9, 8, 6]),
signature: assetSignature,
publicKey: keyPair.publicKey,
),
throwsA(isA<PrecompiledGenerationException>()),
);
for (final substitution in <PrecompiledGenerationManifest>[
PrecompiledGenerationManifest(
generationHash:
'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
sourceCommit: _sourceCommit,
provenance: PrecompiledGenerationProvenance.fromRecipe(recipe),
assets: [
PrecompiledAsset(name: 'asset.bin', length: 3, sha256: assetHash),
],
compositeChecksums: const [],
),
PrecompiledGenerationManifest(
generationHash: _generationHash,
sourceCommit: _sourceCommit,
provenance: PrecompiledGenerationProvenance.fromRecipe(recipe),
assets: [
PrecompiledAsset(name: 'other.bin', length: 3, sha256: assetHash),
],
compositeChecksums: const [],
),
PrecompiledGenerationManifest(
generationHash: _generationHash,
sourceCommit: _sourceCommit,
provenance: PrecompiledGenerationProvenance.fromRecipe(recipe),
assets: [
PrecompiledAsset(name: 'asset.bin', length: 4, sha256: assetHash),
],
compositeChecksums: const [],
),
PrecompiledGenerationManifest(
generationHash: _generationHash,
sourceCommit: _sourceCommit,
provenance: PrecompiledGenerationProvenance.fromRecipe(recipe),
assets: const [
PrecompiledAsset(
name: 'asset.bin',
length: 3,
sha256:
'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
),
],
compositeChecksums: const [],
),
]) {
expect(
() => substitution.verifyAssetMetadataSignature(
name: substitution.assets.single.name,
signature: assetSignature,
publicKey: keyPair.publicKey,
),
throwsA(isA<PrecompiledGenerationException>()),
);
}
final badManifestSignature = Uint8List.fromList(manifestSignature)
..[0] ^= 1;
expect(
() => manifest.verifySignature(keyPair.publicKey, badManifestSignature),
throwsA(isA<PrecompiledGenerationException>()),
);
});
test('zip/checksum relationship binds checksum contents to archive digest',
() {
final archive = Uint8List.fromList([1, 2, 3, 4]);
final checksum =
Uint8List.fromList(utf8.encode('${sha256.convert(archive)}\n'));
final manifest = PrecompiledGenerationManifest(
generationHash: _generationHash,
sourceCommit: _sourceCommit,
provenance: PrecompiledGenerationProvenance.fromRecipe(recipe),
assets: [
PrecompiledAsset(
name: 'fjs.xcframework.zip',
length: archive.length,
sha256: sha256.convert(archive).toString(),
),
PrecompiledAsset(
name: 'fjs.xcframework.zip.checksum',
length: checksum.length,
sha256: sha256.convert(checksum).toString(),
),
],
compositeChecksums: const [
PrecompiledCompositeChecksum(
archive: 'fjs.xcframework.zip',
checksum: 'fjs.xcframework.zip.checksum',
),
],
);
manifest.validateCompositeChecksums({
'fjs.xcframework.zip': archive,
'fjs.xcframework.zip.checksum': checksum,
});
expect(
() => manifest.validateCompositeChecksums({
'fjs.xcframework.zip': archive,
'fjs.xcframework.zip.checksum': Uint8List.fromList(utf8.encode(
'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\n',
)),
}),
throwsA(isA<PrecompiledGenerationException>()),
);
});
test('composite checksum content is exact lowercase sha256 plus LF', () {
final archive = Uint8List.fromList([1, 2, 3, 4]);
final digest = sha256.convert(archive).toString();
final manifest = PrecompiledGenerationManifest(
generationHash: _generationHash,
sourceCommit: _sourceCommit,
provenance: PrecompiledGenerationProvenance.fromRecipe(recipe),
assets: [
PrecompiledAsset(
name: 'archive.zip',
length: archive.length,
sha256: digest,
),
PrecompiledAsset(
name: 'archive.zip.checksum',
length: 65,
sha256: sha256.convert(ascii.encode('$digest\n')).toString(),
),
],
compositeChecksums: const [
PrecompiledCompositeChecksum(
archive: 'archive.zip',
checksum: 'archive.zip.checksum',
),
],
);
manifest.validateCompositeChecksums({
'archive.zip': archive,
'archive.zip.checksum': Uint8List.fromList(ascii.encode('$digest\n')),
});
final invalid = <String, List<int>>{
'no newline': ascii.encode(digest),
'CRLF': ascii.encode('$digest\r\n'),
'extra newline': ascii.encode('$digest\n\n'),
'trailing space': ascii.encode('$digest \n'),
'uppercase': ascii.encode('${digest.toUpperCase()}\n'),
'invalid UTF-8': [...ascii.encode(digest), 0xff],
};
for (final entry in invalid.entries) {
expect(
() => manifest.validateCompositeChecksums({
'archive.zip': archive,
'archive.zip.checksum': entry.value,
}),
throwsA(isA<PrecompiledGenerationException>()),
reason: entry.key,
);
}
});
test('composite metadata is normative without downloading assets', () {
final valid = jsonDecode(utf8.decode(_manifest(recipe).canonicalBytes()))
as Map<String, dynamic>;
final assets = (valid['assets'] as List).cast<Map<String, dynamic>>();
final bindings =
(valid['composite_checksums'] as List).cast<Map<String, dynamic>>();
expect(
() => _parse({
...valid,
'composite_checksums': [
{...bindings.single, 'checksum': bindings.single['archive']},
],
}),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(
() => _parse({
...valid,
'composite_checksums': [
bindings.single,
{
...bindings.single,
'archive': 'aarch64-apple-darwin_libfjs.a',
},
],
}),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(
() => _parse({
...valid,
'assets': [
...assets.take(2),
{...assets.last, 'length': 64},
],
}),
throwsA(isA<PrecompiledGenerationException>()),
);
expect(
() => _parse({
...valid,
'assets': [
...assets.take(2),
{...assets.last, 'sha256': _archiveHash},
],
}),
throwsA(isA<PrecompiledGenerationException>()),
);
});
}
PrecompiledGenerationManifest _manifest(
PrecompiledBuildRecipe recipe, {
bool reverseAssets = false,
}) {
final assets = [
const PrecompiledAsset(
name: 'aarch64-apple-darwin_libfjs.a',
length: 3,
sha256: _checksumHash,
),
const PrecompiledAsset(
name: 'fjs.xcframework.zip',
length: 4,
sha256: _archiveHash,
),
const PrecompiledAsset(
name: 'fjs.xcframework.zip.checksum',
length: 65,
sha256: _checksumHash,
),
];
return PrecompiledGenerationManifest(
generationHash: _generationHash,
sourceCommit: _sourceCommit,
provenance: PrecompiledGenerationProvenance.fromRecipe(recipe),
assets: reverseAssets ? assets.reversed.toList() : assets,
compositeChecksums: const [
PrecompiledCompositeChecksum(
archive: 'fjs.xcframework.zip',
checksum: 'fjs.xcframework.zip.checksum',
),
],
);
}
PrecompiledGenerationManifest _parse(Map<String, dynamic> value) =>
PrecompiledGenerationManifest.parse(utf8.encode(jsonEncode(value)));
String _hex(List<int> bytes) =>
bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
@@ -0,0 +1,263 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:build_tool/src/builder.dart';
import 'package:build_tool/src/options.dart';
import 'package:build_tool/src/precompile_binaries.dart';
import 'package:build_tool/src/precompiled_asset_store.dart';
import 'package:build_tool/src/precompiled_generation.dart';
import 'package:build_tool/src/target.dart';
import 'package:ed25519_edwards/ed25519_edwards.dart';
import 'package:github/github.dart' as github;
import 'package:hex/hex.dart';
import 'package:http/http.dart';
import 'package:http/testing.dart';
import 'package:path/path.dart' as path;
import 'package:test/test.dart';
void main() {
late Directory temp;
late Directory manifestDir;
late KeyPair keyPair;
setUp(() {
temp = Directory(Directory.systemTemp.resolveSymbolicLinksSync())
.createTempSync('precompiled-publisher-test.');
manifestDir = Directory(path.join(temp.path, 'crate'))..createSync();
final privateKey = newKeyFromSeed(
Uint8List.fromList(List<int>.generate(32, (index) => index)),
);
keyPair = KeyPair(privateKey, public(privateKey));
_writeCrate(manifestDir, HEX.encode(keyPair.publicKey.bytes));
});
tearDown(() => temp.deleteSync(recursive: true));
test('finalizer output is accepted by the v2 store without extra assets',
() async {
final finalized = await _finalize(temp, manifestDir, keyPair);
final remote = <String, List<int>>{
for (final entry in finalized.publicationFiles.entries)
entry.key: entry.value.readAsBytesSync(),
};
final requests = <String>[];
final transport = PrecompiledAssetTransport(send: (request) async {
final name = request.url.pathSegments.last;
requests.add(name);
final bytes = remote[name];
return StreamedResponse(
Stream.value(bytes ?? const <int>[]),
bytes == null ? 404 : 200,
contentLength: bytes?.length ?? 0,
);
});
final options = CargokitCrateOptions.load(manifestDir: manifestDir.path);
final recipe = options.precompiledBinaries!.buildRecipe!;
final store = PrecompiledAssetStore(
cacheRoot: path.join(temp.path, 'cache'),
uriPrefix: Uri.parse('https://assets.example/precompiled_'),
publicKey: keyPair.publicKey,
recipe: recipe,
expectedAssetNames:
finalized.manifest.assets.map((asset) => asset.name).toSet(),
expectedCompositeChecksums: const {},
transport: transport,
);
final requested =
'${Target.forRustTriple('x86_64-unknown-linux-gnu')!.rust}_libfjs.a';
final snapshot = await store.snapshot(
generationHash: finalized.manifest.generationHash,
requestedAssetNames: {requested},
);
expect(File(snapshot!.pathFor(requested)).readAsStringSync(), 'static');
expect(requests, isNot(contains(endsWith('_libfjs.so'))));
transport.close();
});
test('publisher uploads completion last, reuses valid, and rejects mismatch',
() async {
final finalized = await _finalize(temp, manifestDir, keyPair);
final gateway = _FakeReleaseGateway();
final publisher = PrecompiledGenerationPublisher(
generation: finalized,
gateway: gateway,
);
final first = await publisher.publish();
expect(first.reused, isFalse);
expect(gateway.events.sublist(gateway.events.length - 3), [
'upload:completion.json',
'upload:completion.json.sig',
'publish',
]);
gateway.events.clear();
final reused = await publisher.publish();
expect(reused.reused, isTrue);
expect(
gateway.events.where((event) => event.startsWith('upload:')), isEmpty);
gateway.corrupt('completion.json');
await expectLater(
publisher.publish(),
throwsA(isA<PrecompiledGenerationException>()),
);
});
test('GitHub gateway downloads an authenticated release asset', () async {
final client = MockClient((request) async {
expect(request.method, 'GET');
expect(
request.url,
Uri.parse(
'https://api.github.com/repos/fluttercandies/fjs/releases/assets/42',
),
);
expect(request.headers['Accept'], 'application/octet-stream');
expect(request.headers['Authorization'], isNotEmpty);
expect(request.headers['User-Agent'], isNotEmpty);
return Response.bytes(const [1, 2, 3], 200);
});
final githubClient = github.GitHub(
auth: const github.Authentication.withToken('token'),
client: client,
);
addTearDown(githubClient.dispose);
final gateway = GitHubPrecompiledReleaseGateway(
repositories: githubClient.repositories,
repository: github.RepositorySlug('fluttercandies', 'fjs'),
);
final release = PrecompiledRelease(
id: github.Release(
id: 7,
assets: [github.ReleaseAsset(id: 42, name: 'completion.json')],
),
tagName: 'precompiled_hash',
isDraft: false,
assetNames: const {'completion.json'},
);
expect(
await gateway.readAsset(release, 'completion.json'),
const [1, 2, 3],
);
});
}
Future<FinalizedPrecompiledGeneration> _finalize(
Directory temp,
Directory manifestDir,
KeyPair keyPair,
) async {
final generationDir = path.join(temp.path, 'generation');
await LocalPrecompiledGeneration(
manifestDir: manifestDir.path,
outputDir: generationDir,
tempDir: path.join(temp.path, 'build'),
targetTriples: const ['x86_64-unknown-linux-gnu'],
targetBuilder: _buildTarget,
).run();
return LocalGenerationFinalizer(
manifestDir: manifestDir.path,
generationDir: generationDir,
privateKey: keyPair.privateKey,
scope: LocalGenerationFinalizationScope.full,
sourceCommit: '1111111111111111111111111111111111111111',
).run();
}
class _FakeReleaseGateway implements PrecompiledReleaseGateway {
PrecompiledRelease? release;
final assets = <String, List<int>>{};
final events = <String>[];
@override
Future<PrecompiledRelease?> find(String tagName) async => release;
@override
Future<PrecompiledRelease> createDraft(String tagName) async {
events.add('create-draft');
return release = PrecompiledRelease(
id: 'release',
tagName: tagName,
isDraft: true,
assetNames: assets.keys.toSet(),
);
}
@override
Future<List<int>> readAsset(PrecompiledRelease release, String name) async =>
assets[name]!;
@override
Future<void> upload(
PrecompiledRelease release,
String name,
List<int> bytes,
) async {
events.add('upload:$name');
assets[name] = List.of(bytes);
this.release = PrecompiledRelease(
id: release.id,
tagName: release.tagName,
isDraft: true,
assetNames: assets.keys.toSet(),
);
}
@override
Future<void> publish(PrecompiledRelease release) async {
events.add('publish');
this.release = PrecompiledRelease(
id: release.id,
tagName: release.tagName,
isDraft: false,
assetNames: assets.keys.toSet(),
);
}
void corrupt(String name) => assets[name]![0] ^= 1;
}
Future<String> _buildTarget(
Target target,
BuildEnvironment environment,
String toolchain,
) async {
final output = Directory(path.join(environment.targetTempDir, target.rust))
..createSync(recursive: true);
File(path.join(output.path, 'libfjs.a')).writeAsStringSync('static');
File(path.join(output.path, 'libfjs.so')).writeAsStringSync('dynamic');
return output.path;
}
void _writeCrate(Directory manifestDir, String publicKey) {
File(path.join(manifestDir.path, 'Cargo.toml')).writeAsStringSync('''
[package]
name = "fjs"
version = "1.0.0"
''');
Directory(path.join(manifestDir.path, 'src')).createSync();
File(path.join(manifestDir.path, 'src/lib.rs'))
.writeAsStringSync('pub fn fjs() {}\n');
File(path.join(manifestDir.path, 'cargokit.yaml')).writeAsStringSync('''
precompiled_binaries:
url_prefix: https://assets.example/precompiled_
public_key: $publicKey
workspace_root: .
build_recipe:
rust_toolchain: '1.88.0'
flutter_version: '3.32.8'
xcode_version: '16.4'
sdk_versions:
linux: '6.8'
deployment_targets:
linux: 'glibc-2.35'
rust_targets:
- x86_64-unknown-linux-gnu
''');
}
@@ -0,0 +1,181 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:build_tool/src/options.dart';
import 'package:build_tool/src/precompiled_asset_store.dart';
import 'package:ed25519_edwards/ed25519_edwards.dart';
Future<void> main(List<String> arguments) async {
if (arguments.length != 1) {
stderr.writeln('Expected one JSON configuration argument.');
exitCode = 64;
return;
}
final config = (jsonDecode(arguments.single) as Map).cast<String, dynamic>();
final mode = config['mode'] as String;
_emit({'event': 'ready', 'mode': mode});
final commands = StreamIterator(
stdin.transform(utf8.decoder).transform(const LineSplitter()),
);
if (!await commands.moveNext()) return;
final first = (jsonDecode(commands.current) as Map).cast<String, dynamic>();
if (first['command'] != 'proceed') {
_emit({
'event': 'result',
'ok': false,
'path': null,
'error': 'expected proceed',
});
await commands.cancel();
exitCode = 64;
return;
}
try {
if (mode == 'install') {
try {
final snapshotPath = await _install(config);
_emit({
'event': 'result',
'ok': true,
'path': snapshotPath,
'error': null,
});
} on Object catch (error) {
_emit({
'event': 'result',
'ok': false,
'path': null,
'error': '$error',
});
exitCode = 1;
}
await commands.cancel();
return;
}
if (!const {
'hold-generation-lock',
'hold-request-lock',
'crash-with-lock',
'acquire',
}.contains(mode)) {
throw ArgumentError.value(mode, 'mode', 'Unsupported worker mode.');
}
final lockPath = config['lock_path'] as String;
final file = File(lockPath)..createSync(recursive: true);
final lock = file.openSync(mode: FileMode.append);
if (mode == 'acquire') {
final acquired = await _acquire(
lock,
timeout: Duration(milliseconds: (config['timeout_ms'] as int?) ?? 1000),
poll: Duration(milliseconds: (config['poll_ms'] as int?) ?? 10),
);
_emit({
'event': 'result',
'ok': acquired,
'path': null,
'error': acquired ? null : 'lock not acquired',
'acquired': acquired,
});
if (acquired) lock.unlockSync();
lock.closeSync();
await commands.cancel();
return;
}
lock.lockSync(FileLock.exclusive);
_emit({
'event': 'result',
'ok': true,
'path': null,
'error': null,
'acquired': true,
});
if (mode == 'crash-with-lock') {
exit(23);
}
if (await commands.moveNext()) {
final command =
(jsonDecode(commands.current) as Map).cast<String, dynamic>();
if (command['command'] == 'release') {
lock.unlockSync();
lock.closeSync();
await commands.cancel();
return;
}
}
lock.unlockSync();
lock.closeSync();
await commands.cancel();
} on Object catch (error, stackTrace) {
_emit({
'event': 'result',
'ok': false,
'path': null,
'error': '$error',
'stack': '$stackTrace',
});
await commands.cancel();
exitCode = 1;
}
}
Future<bool> _acquire(
RandomAccessFile lock, {
required Duration timeout,
required Duration poll,
}) async {
final stopwatch = Stopwatch()..start();
while (true) {
try {
lock.lockSync(FileLock.exclusive);
return true;
} on FileSystemException {
if (stopwatch.elapsed >= timeout) return false;
await Future<void>.delayed(poll);
}
}
}
Future<String?> _install(Map<String, dynamic> config) async {
final recipeJson = (config['recipe'] as Map).cast<String, dynamic>();
final recipe = PrecompiledBuildRecipe(
rustToolchain: recipeJson['rust_toolchain'] as String,
flutterVersion: recipeJson['flutter_version'] as String,
xcodeVersion: recipeJson['xcode_version'] as String,
sdkVersions: (recipeJson['sdk_versions'] as Map).cast<String, String>(),
deploymentTargets:
(recipeJson['deployment_targets'] as Map).cast<String, String>(),
rustTargets: (recipeJson['rust_targets'] as List).cast<String>(),
);
final transport = PrecompiledAssetTransport();
try {
final store = PrecompiledAssetStore(
cacheRoot: config['cache_root'] as String,
uriPrefix: Uri.parse(config['uri_prefix'] as String),
publicKey: PublicKey((config['public_key'] as List).cast<int>()),
recipe: recipe,
expectedAssetNames:
(config['expected_asset_names'] as List).cast<String>().toSet(),
expectedCompositeChecksums:
(config['expected_composites'] as List).cast<String>().toSet(),
transport: transport,
policy: PrecompiledAssetStorePolicy(
lockTimeout: Duration(
milliseconds: (config['lock_timeout_ms'] as int?) ?? 30000),
),
);
final snapshot = await store.snapshot(
generationHash: config['generation_hash'] as String,
requestedAssetNames:
(config['requested_assets'] as List).cast<String>().toSet(),
);
return snapshot?.directory;
} finally {
transport.close();
}
}
void _emit(Map<String, Object?> value) {
stdout.writeln(jsonEncode(value));
}
@@ -0,0 +1,99 @@
SET(cargokit_cmake_root "${CMAKE_CURRENT_LIST_DIR}/..")
# Workaround for https://github.com/dart-lang/pub/issues/4010
get_filename_component(cargokit_cmake_root "${cargokit_cmake_root}" REALPATH)
if(WIN32)
# REALPATH does not properly resolve symlinks on windows :-/
execute_process(COMMAND powershell -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_LIST_DIR}/resolve_symlinks.ps1" "${cargokit_cmake_root}" OUTPUT_VARIABLE cargokit_cmake_root OUTPUT_STRIP_TRAILING_WHITESPACE)
endif()
# Arguments
# - target: CMAKE target to which rust library is linked
# - manifest_dir: relative path from current folder to directory containing cargo manifest
# - lib_name: cargo package name
# - any_symbol_name: name of any exported symbol from the library.
# used on windows to force linking with library.
function(apply_cargokit target manifest_dir lib_name any_symbol_name)
set(CARGOKIT_LIB_NAME "${lib_name}")
set(CARGOKIT_LIB_FULL_NAME "${CMAKE_SHARED_MODULE_PREFIX}${CARGOKIT_LIB_NAME}${CMAKE_SHARED_MODULE_SUFFIX}")
if (CMAKE_CONFIGURATION_TYPES)
set(CARGOKIT_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/$<CONFIG>")
set(OUTPUT_LIB "${CMAKE_CURRENT_BINARY_DIR}/$<CONFIG>/${CARGOKIT_LIB_FULL_NAME}")
else()
set(CARGOKIT_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}")
set(OUTPUT_LIB "${CMAKE_CURRENT_BINARY_DIR}/${CARGOKIT_LIB_FULL_NAME}")
endif()
set(CARGOKIT_TEMP_DIR "${CMAKE_CURRENT_BINARY_DIR}/cargokit_build")
if (FLUTTER_TARGET_PLATFORM)
set(CARGOKIT_TARGET_PLATFORM "${FLUTTER_TARGET_PLATFORM}")
else()
set(CARGOKIT_TARGET_PLATFORM "windows-x64")
endif()
set(CARGOKIT_ENV
"CARGOKIT_CMAKE=${CMAKE_COMMAND}"
"CARGOKIT_CONFIGURATION=$<CONFIG>"
"CARGOKIT_MANIFEST_DIR=${CMAKE_CURRENT_SOURCE_DIR}/${manifest_dir}"
"CARGOKIT_TARGET_TEMP_DIR=${CARGOKIT_TEMP_DIR}"
"CARGOKIT_OUTPUT_DIR=${CARGOKIT_OUTPUT_DIR}"
"CARGOKIT_TARGET_PLATFORM=${CARGOKIT_TARGET_PLATFORM}"
"CARGOKIT_TOOL_TEMP_DIR=${CARGOKIT_TEMP_DIR}/tool"
"CARGOKIT_ROOT_PROJECT_DIR=${CMAKE_SOURCE_DIR}"
)
if (WIN32)
set(SCRIPT_EXTENSION ".cmd")
set(IMPORT_LIB_EXTENSION ".lib")
else()
set(SCRIPT_EXTENSION ".sh")
set(IMPORT_LIB_EXTENSION "")
execute_process(COMMAND chmod +x "${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}")
endif()
# Using generators in custom command is only supported in CMake 3.20+
if (CMAKE_CONFIGURATION_TYPES AND ${CMAKE_VERSION} VERSION_LESS "3.20.0")
foreach(CONFIG IN LISTS CMAKE_CONFIGURATION_TYPES)
add_custom_command(
OUTPUT
"${CMAKE_CURRENT_BINARY_DIR}/${CONFIG}/${CARGOKIT_LIB_FULL_NAME}"
"${CMAKE_CURRENT_BINARY_DIR}/_phony_"
COMMAND ${CMAKE_COMMAND} -E env ${CARGOKIT_ENV}
"${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}" build-cmake
VERBATIM
)
endforeach()
else()
add_custom_command(
OUTPUT
${OUTPUT_LIB}
"${CMAKE_CURRENT_BINARY_DIR}/_phony_"
COMMAND ${CMAKE_COMMAND} -E env ${CARGOKIT_ENV}
"${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}" build-cmake
VERBATIM
)
endif()
set_source_files_properties("${CMAKE_CURRENT_BINARY_DIR}/_phony_" PROPERTIES SYMBOLIC TRUE)
if (TARGET ${target})
# If we have actual cmake target provided create target and make existing
# target depend on it
add_custom_target("${target}_cargokit" DEPENDS ${OUTPUT_LIB})
add_dependencies("${target}" "${target}_cargokit")
target_link_libraries("${target}" PRIVATE "${OUTPUT_LIB}${IMPORT_LIB_EXTENSION}")
if(WIN32)
target_link_options(${target} PRIVATE "/INCLUDE:${any_symbol_name}")
endif()
else()
# Otherwise (FFI) just use ALL to force building always
add_custom_target("${target}_cargokit" ALL DEPENDS ${OUTPUT_LIB})
endif()
# Allow adding the output library to plugin bundled libraries
set("${target}_cargokit_lib" ${OUTPUT_LIB} PARENT_SCOPE)
endfunction()
@@ -0,0 +1,27 @@
function Resolve-Symlinks {
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Position = 0, Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
[string] $Path
)
[string] $separator = '/'
[string[]] $parts = $Path.Split($separator)
[string] $realPath = ''
foreach ($part in $parts) {
if ($realPath -and !$realPath.EndsWith($separator)) {
$realPath += $separator
}
$realPath += $part
$item = Get-Item $realPath
if ($item.Target) {
$realPath = $item.Target.Replace('\', '/')
}
}
$realPath
}
$path=Resolve-Symlinks -Path $args[0]
Write-Host $path
@@ -0,0 +1,196 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import org.apache.tools.ant.taskdefs.condition.Os
import org.gradle.process.ExecOperations
import javax.inject.Inject
import java.nio.file.Paths
CargoKitPlugin.file = buildscript.sourceFile
apply plugin: CargoKitPlugin
class CargoKitExtension {
String manifestDir; // Relative path to folder containing Cargo.toml
String libname; // Library name within Cargo.toml. Must be a cdylib
}
abstract class CargoKitBuildTask extends DefaultTask {
@Inject
abstract ExecOperations getExecOperations()
@Input
String buildMode
@Input
String buildDir
@Input
String outputDir
@Input
String ndkVersion
@Input
String sdkDirectory
@Input
int compileSdkVersion;
@Input
int minSdkVersion;
@Input
String pluginFile
@Input
List<String> targetPlatforms
@TaskAction
def build() {
if (project.cargokit.manifestDir == null) {
throw new GradleException("Property 'manifestDir' must be set on cargokit extension");
}
if (project.cargokit.libname == null) {
throw new GradleException("Property 'libname' must be set on cargokit extension");
}
def executableName = Os.isFamily(Os.FAMILY_WINDOWS) ? "run_build_tool.cmd" : "run_build_tool.sh"
def path = Paths.get(new File(pluginFile).parent, "..", executableName);
def manifestDir = Paths.get(project.buildscript.sourceFile.parent, project.cargokit.manifestDir)
def rootProjectDir = project.rootProject.projectDir
if (!Os.isFamily(Os.FAMILY_WINDOWS)) {
execOperations.exec {
commandLine 'chmod', '+x', path
}
}
execOperations.exec {
executable path
args "build-gradle"
environment "CARGOKIT_ROOT_PROJECT_DIR", rootProjectDir
environment "CARGOKIT_TOOL_TEMP_DIR", "${buildDir}/build_tool"
environment "CARGOKIT_MANIFEST_DIR", manifestDir
environment "CARGOKIT_CONFIGURATION", buildMode
environment "CARGOKIT_TARGET_TEMP_DIR", buildDir
environment "CARGOKIT_OUTPUT_DIR", outputDir
environment "CARGOKIT_NDK_VERSION", ndkVersion
environment "CARGOKIT_SDK_DIR", sdkDirectory
environment "CARGOKIT_COMPILE_SDK_VERSION", compileSdkVersion
environment "CARGOKIT_MIN_SDK_VERSION", minSdkVersion
environment "CARGOKIT_TARGET_PLATFORMS", targetPlatforms.join(",")
environment "CARGOKIT_JAVA_HOME", System.properties['java.home']
}
}
}
class CargoKitPlugin implements Plugin<Project> {
static String file;
private Plugin findFlutterPlugin(Project rootProject) {
_findFlutterPlugin(rootProject.childProjects)
}
private Plugin _findFlutterPlugin(Map projects) {
for (project in projects) {
for (plugin in project.value.getPlugins()) {
if (plugin.class.name == "FlutterPlugin" || plugin.class.name == "com.flutter.gradle.FlutterPlugin") {
return plugin;
}
}
def plugin = _findFlutterPlugin(project.value.childProjects);
if (plugin != null) {
return plugin;
}
}
return null;
}
@Override
void apply(Project project) {
def plugin = findFlutterPlugin(project.rootProject);
project.extensions.create("cargokit", CargoKitExtension)
if (plugin == null) {
print("Flutter plugin not found, CargoKit plugin will not be applied.")
return;
}
def projectBuildDir = project.layout.buildDirectory.get().asFile
def cargoBuildDir = new File(projectBuildDir, "build").path
// Determine if the project is an application or library
def isApplication = plugin.project.plugins.hasPlugin('com.android.application')
def variants = isApplication ? plugin.project.android.applicationVariants : plugin.project.android.libraryVariants
variants.all { variant ->
final buildType = variant.buildType.name
def cargoOutputDir = new File(projectBuildDir, "jniLibs/${buildType}").path;
def jniLibs = project.android.sourceSets.maybeCreate(buildType).jniLibs;
jniLibs.srcDir(new File(cargoOutputDir))
def platforms;
if (plugin.metaClass.respondsTo(plugin, 'getTargetPlatforms')) {
platforms = plugin.getTargetPlatforms().collect();
} else {
platforms = com.flutter.gradle.FlutterPluginUtils.getTargetPlatforms(project).collect();
}
// Same thing addFlutterDependencies does in flutter.gradle
if (buildType == "debug") {
platforms.add("android-x64")
platforms.add("android-arm64")
}
// platforms remove duplicates
platforms = platforms.unique()
// The task name depends on plugin properties, which are not available
// at this point
project.getGradle().afterProject {
def taskName = "cargokitCargoBuild${project.cargokit.libname.capitalize()}${buildType.capitalize()}";
if (project.tasks.findByName(taskName)) {
return
}
if (plugin.project.android.ndkVersion == null) {
throw new GradleException("Please set 'android.ndkVersion' in 'app/build.gradle'.")
}
def task = project.tasks.create(taskName, CargoKitBuildTask.class) {
buildMode = variant.buildType.name
buildDir = cargoBuildDir
outputDir = cargoOutputDir
ndkVersion = plugin.project.android.ndkVersion
sdkDirectory = plugin.project.android.sdkDirectory
minSdkVersion = plugin.project.android.defaultConfig.minSdkVersion.apiLevel as int
compileSdkVersion = plugin.project.android.compileSdkVersion.substring(8) as int
targetPlatforms = platforms
pluginFile = CargoKitPlugin.file
}
def onTask = { newTask ->
if (newTask.name == "merge${buildType.capitalize()}NativeLibs") {
newTask.dependsOn task
// Fix gradle 7.4.2 not picking up JNI library changes
newTask.outputs.upToDateWhen { false }
}
}
project.tasks.each onTask
project.tasks.whenTaskAdded onTask
}
}
}
}
@@ -0,0 +1,91 @@
@echo off
setlocal
setlocal ENABLEDELAYEDEXPANSION
SET BASEDIR=%~dp0
if not exist "%CARGOKIT_TOOL_TEMP_DIR%" (
mkdir "%CARGOKIT_TOOL_TEMP_DIR%"
)
cd /D "%CARGOKIT_TOOL_TEMP_DIR%"
SET BUILD_TOOL_PKG_DIR=%BASEDIR%build_tool
SET DART=%FLUTTER_ROOT%\bin\cache\dart-sdk\bin\dart
set BUILD_TOOL_PKG_DIR_POSIX=%BUILD_TOOL_PKG_DIR:\=/%
(
echo name: build_tool_runner
echo version: 1.0.0
echo publish_to: none
echo.
echo environment:
echo sdk: '^>=3.0.0 ^<4.0.0'
echo.
echo dependencies:
echo build_tool:
echo path: %BUILD_TOOL_PKG_DIR_POSIX%
) >pubspec.yaml
if not exist bin (
mkdir bin
)
(
echo import 'package:build_tool/build_tool.dart' as build_tool;
echo void main^(List^<String^> args^) ^{
echo build_tool.runMain^(args^);
echo ^}
) >bin\build_tool_runner.dart
SET PRECOMPILED=bin\build_tool_runner.dill
REM To detect changes in package we compare output of DIR /s (recursive)
set PREV_PACKAGE_INFO=.dart_tool\package_info.prev
set CUR_PACKAGE_INFO=.dart_tool\package_info.cur
DIR "%BUILD_TOOL_PKG_DIR%" /s > "%CUR_PACKAGE_INFO%_orig"
REM Last line in dir output is free space on harddrive. That is bound to
REM change between invocation so we need to remove it
(
Set "Line="
For /F "UseBackQ Delims=" %%A In ("%CUR_PACKAGE_INFO%_orig") Do (
SetLocal EnableDelayedExpansion
If Defined Line Echo !Line!
EndLocal
Set "Line=%%A")
) >"%CUR_PACKAGE_INFO%"
DEL "%CUR_PACKAGE_INFO%_orig"
REM Compare current directory listing with previous
FC /B "%CUR_PACKAGE_INFO%" "%PREV_PACKAGE_INFO%" > nul 2>&1
If %ERRORLEVEL% neq 0 (
REM Changed - copy current to previous and remove precompiled kernel
if exist "%PREV_PACKAGE_INFO%" (
DEL "%PREV_PACKAGE_INFO%"
)
MOVE /Y "%CUR_PACKAGE_INFO%" "%PREV_PACKAGE_INFO%"
if exist "%PRECOMPILED%" (
DEL "%PRECOMPILED%"
)
)
REM There is no CUR_PACKAGE_INFO it was renamed in previous step to %PREV_PACKAGE_INFO%
REM which means we need to do pub get and precompile
if not exist "%PRECOMPILED%" (
echo Running pub get in "%cd%"
"%DART%" pub get --no-precompile
"%DART%" compile kernel bin/build_tool_runner.dart
)
"%DART%" "%PRECOMPILED%" %*
REM 253 means invalid snapshot version.
If %ERRORLEVEL% equ 253 (
"%DART%" pub get --no-precompile
"%DART%" compile kernel bin/build_tool_runner.dart
"%DART%" "%PRECOMPILED%" %*
)
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
set -e
BASEDIR=$(dirname "$0")
mkdir -p "$CARGOKIT_TOOL_TEMP_DIR"
cd "$CARGOKIT_TOOL_TEMP_DIR"
# Write a very simple bin package in temp folder that depends on build_tool package
# from Cargokit. This is done to ensure that we don't pollute Cargokit folder
# with .dart_tool contents.
BUILD_TOOL_PKG_DIR="$BASEDIR/build_tool"
if [[ -z $FLUTTER_ROOT ]]; then # not defined
DART=dart
else
DART="$FLUTTER_ROOT/bin/cache/dart-sdk/bin/dart"
fi
cat << EOF > "pubspec.yaml"
name: build_tool_runner
version: 1.0.0
publish_to: none
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
build_tool:
path: "$BUILD_TOOL_PKG_DIR"
EOF
mkdir -p "bin"
cat << EOF > "bin/build_tool_runner.dart"
import 'package:build_tool/build_tool.dart' as build_tool;
void main(List<String> args) {
build_tool.runMain(args);
}
EOF
# Create alias for `shasum` if it does not exist and `sha1sum` exists
if ! [ -x "$(command -v shasum)" ] && [ -x "$(command -v sha1sum)" ]; then
shopt -s expand_aliases
alias shasum="sha1sum"
fi
# Dart run will not cache any package that has a path dependency, which
# is the case for our build_tool_runner. So instead we precompile the package
# ourselves.
# To invalidate the cached kernel we use the hash of ls -LR of the build_tool
# package directory. This should be good enough, as the build_tool package
# itself is not meant to have any path dependencies.
if [[ "$OSTYPE" == "darwin"* ]]; then
PACKAGE_HASH=$(ls -lTR "$BUILD_TOOL_PKG_DIR" | shasum)
else
PACKAGE_HASH=$(ls -lR --full-time "$BUILD_TOOL_PKG_DIR" | shasum)
fi
PACKAGE_HASH_FILE=".package_hash"
if [ -f "$PACKAGE_HASH_FILE" ]; then
EXISTING_HASH=$(cat "$PACKAGE_HASH_FILE")
if [ "$PACKAGE_HASH" != "$EXISTING_HASH" ]; then
rm "$PACKAGE_HASH_FILE"
fi
fi
# Run pub get if needed.
if [ ! -f "$PACKAGE_HASH_FILE" ]; then
"$DART" pub get --no-precompile
"$DART" compile kernel bin/build_tool_runner.dart
echo "$PACKAGE_HASH" > "$PACKAGE_HASH_FILE"
fi
set +e
"$DART" bin/build_tool_runner.dill "$@"
exit_code=$?
# 253 means invalid snapshot version.
if [ $exit_code == 253 ]; then
"$DART" pub get --no-precompile
"$DART" compile kernel bin/build_tool_runner.dart
"$DART" bin/build_tool_runner.dill "$@"
exit_code=$?
fi
exit $exit_code
@@ -0,0 +1 @@
// This is an empty file to force CocoaPods to create a framework.
@@ -0,0 +1,29 @@
Pod::Spec.new do |s|
s.name = 'komet_tlottie'
s.version = '0.1.0'
s.summary = 'tlottie Lottie renderer for Komet.'
s.description = <<-DESC
Builds the tlottie C ABI with cargokit and links it into the app, where dart:ffi reaches it.
DESC
s.homepage = 'https://github.com/dkaraush/tlottie'
s.license = { :file => '../LICENSE' }
s.author = { 'Komet' => 'dev@komet.pw' }
s.source = { :path => '.' }
s.source_files = 'Classes/**/*'
s.dependency 'Flutter'
s.platform = :ios, '13.0'
s.script_phase = {
:name => 'Build Rust library',
:script => 'sh "$PODS_TARGET_SRCROOT/../cargokit/build_pod.sh" ../rust komet_tlottie',
:execution_position => :before_compile,
:input_files => ['${BUILT_PRODUCTS_DIR}/cargokit_phony'],
:output_files => ["${BUILT_PRODUCTS_DIR}/libkomet_tlottie.a"],
}
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
'OTHER_LDFLAGS' => '-force_load ${BUILT_PRODUCTS_DIR}/libkomet_tlottie.a',
}
end
@@ -0,0 +1,91 @@
import 'dart:ffi';
import 'dart:io';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
final class TlottieAnimation extends Opaque {}
typedef _NewNative = Pointer<TlottieAnimation> Function(
Pointer<Uint8>, Size, Uint32, Pointer<Void>, Size, Pointer<Void>, Size, Uint32);
typedef _New = Pointer<TlottieAnimation> Function(
Pointer<Uint8>, int, int, Pointer<Void>, int, Pointer<Void>, int, int);
typedef _DropNative = Void Function(Pointer<TlottieAnimation>);
typedef _Drop = void Function(Pointer<TlottieAnimation>);
typedef _FrameCountNative = Uint32 Function(Pointer<TlottieAnimation>);
typedef _FrameCount = int Function(Pointer<TlottieAnimation>);
typedef _FrameRateNative = Float Function(Pointer<TlottieAnimation>);
typedef _FrameRate = double Function(Pointer<TlottieAnimation>);
typedef _RenderNative = Int32 Function(
Pointer<TlottieAnimation>, Float, Uint32, Uint32, Pointer<Uint32>, Size, Uint32);
typedef _Render = int Function(
Pointer<TlottieAnimation>, double, int, int, Pointer<Uint32>, int, int);
class TlottieBindings {
TlottieBindings._(DynamicLibrary lib)
: _new = lib.lookupFunction<_NewNative, _New>('tlottie_new_with_options'),
_drop = lib.lookupFunction<_DropNative, _Drop>('tlottie_drop'),
_frameCount =
lib.lookupFunction<_FrameCountNative, _FrameCount>('tlottie_frame_count'),
_frameRate =
lib.lookupFunction<_FrameRateNative, _FrameRate>('tlottie_frame_rate'),
_render = lib.lookupFunction<_RenderNative, _Render>('tlottie_render');
static const int _fitzNone = 0;
static const int _channelBgra = 1;
static const int _statusOk = 0;
final _New _new;
final _Drop _drop;
final _FrameCount _frameCount;
final _FrameRate _frameRate;
final _Render _render;
static TlottieBindings? open({String? path}) {
try {
return TlottieBindings._(_openLibrary(path));
} catch (_) {
return null;
}
}
static DynamicLibrary _openLibrary(String? path) {
if (path != null) return DynamicLibrary.open(path);
if (Platform.isIOS || Platform.isMacOS) {
final process = DynamicLibrary.process();
if (process.providesSymbol('tlottie_new_with_options')) return process;
return DynamicLibrary.open('komet_tlottie.framework/komet_tlottie');
}
if (Platform.isWindows) return DynamicLibrary.open('komet_tlottie.dll');
return DynamicLibrary.open('libkomet_tlottie.so');
}
Pointer<TlottieAnimation>? parse(Uint8List json) {
if (json.isEmpty) return null;
final data = malloc<Uint8>(json.length);
try {
data.asTypedList(json.length).setAll(0, json);
final anim = _new(data, json.length, _fitzNone, nullptr, 0, nullptr, 0,
_channelBgra);
return anim == nullptr ? null : anim;
} finally {
malloc.free(data);
}
}
int frameCount(Pointer<TlottieAnimation> anim) => _frameCount(anim);
double frameRate(Pointer<TlottieAnimation> anim) => _frameRate(anim);
bool render(Pointer<TlottieAnimation> anim, int frame, Pointer<Uint32> buffer,
int px) {
return _render(anim, frame.toDouble(), px, px, buffer, px * px, 1) ==
_statusOk;
}
void drop(Pointer<TlottieAnimation> anim) => _drop(anim);
}
+12
View File
@@ -0,0 +1,12 @@
cmake_minimum_required(VERSION 3.10)
set(PROJECT_NAME "komet_tlottie")
project(${PROJECT_NAME} LANGUAGES CXX)
include("../cargokit/cmake/cargokit.cmake")
apply_cargokit(${PROJECT_NAME} ../rust komet_tlottie "")
set(komet_tlottie_bundled_libraries
"${${PROJECT_NAME}_cargokit_lib}"
PARENT_SCOPE
)
@@ -0,0 +1 @@
// This is an empty file to force CocoaPods to create a framework.
@@ -0,0 +1,28 @@
Pod::Spec.new do |s|
s.name = 'komet_tlottie'
s.version = '0.1.0'
s.summary = 'tlottie Lottie renderer for Komet.'
s.description = <<-DESC
Builds the tlottie C ABI with cargokit and links it into the app, where dart:ffi reaches it.
DESC
s.homepage = 'https://github.com/dkaraush/tlottie'
s.license = { :file => '../LICENSE' }
s.author = { 'Komet' => 'dev@komet.pw' }
s.source = { :path => '.' }
s.source_files = 'Classes/**/*'
s.dependency 'FlutterMacOS'
s.platform = :osx, '10.15'
s.script_phase = {
:name => 'Build Rust library',
:script => 'sh "$PODS_TARGET_SRCROOT/../cargokit/build_pod.sh" ../rust komet_tlottie',
:execution_position => :before_compile,
:input_files => ['${BUILT_PRODUCTS_DIR}/cargokit_phony'],
:output_files => ["${BUILT_PRODUCTS_DIR}/libkomet_tlottie.a"],
}
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'OTHER_LDFLAGS' => '-force_load ${BUILT_PRODUCTS_DIR}/libkomet_tlottie.a',
}
end
+27
View File
@@ -0,0 +1,27 @@
name: komet_tlottie
description: Native Lottie renderer (tlottie, Rust) for Komet's animated stickers, animoji and reactions.
version: 0.1.0
publish_to: none
environment:
sdk: ">=3.3.0 <4.0.0"
flutter: ">=3.3.0"
dependencies:
flutter:
sdk: flutter
ffi: ^2.1.0
flutter:
plugin:
platforms:
android:
ffiPlugin: true
ios:
ffiPlugin: true
linux:
ffiPlugin: true
macos:
ffiPlugin: true
windows:
ffiPlugin: true
+15
View File
@@ -0,0 +1,15 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "komet_tlottie"
version = "0.1.0"
dependencies = [
"tlottie",
]
[[package]]
name = "tlottie"
version = "0.1.0"
source = "git+https://github.com/dkaraush/tlottie?rev=31f1b542f88e7b4be9a01e749920d857535fc715#31f1b542f88e7b4be9a01e749920d857535fc715"
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "komet_tlottie"
version = "0.1.0"
edition = "2021"
description = "Builds the tlottie C ABI into a library the Komet app loads through dart:ffi"
license = "MIT"
publish = false
[lib]
crate-type = ["cdylib", "staticlib"]
[dependencies]
tlottie = { git = "https://github.com/dkaraush/tlottie", rev = "31f1b542f88e7b4be9a01e749920d857535fc715", features = ["c-api"] }
[workspace]
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
panic = "abort"
strip = true
[profile.dev.package.tlottie]
opt-level = 3
+1
View File
@@ -0,0 +1 @@
pub use tlottie;
@@ -0,0 +1,12 @@
cmake_minimum_required(VERSION 3.14)
set(PROJECT_NAME "komet_tlottie")
project(${PROJECT_NAME} LANGUAGES CXX)
include("../cargokit/cmake/cargokit.cmake")
apply_cargokit(${PROJECT_NAME} ../rust komet_tlottie "")
set(komet_tlottie_bundled_libraries
"${${PROJECT_NAME}_cargokit_lib}"
PARENT_SCOPE
)