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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,14 @@ flow in ownership order:

## Native And Web Asset Sync

Apple llama.cpp process lookup requires the resolved companion's package
identity and SwiftPM pin to match the core native pin. Preserve this guard and
its metadata cache dependencies when changing sync or hook behavior; declared
version constraints and core native overrides are not ABI evidence. Unverified
local companion `Artifacts` overrides must fail closed.
Changes to the companion SwiftPM implementation require reviewing/updating the
hook's normalized manifest template contract; tag/checksum-only syncs do not.

Prefer the repository workflow for native version and binding updates:
`.github/workflows/sync_native_bindings.yml`.

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## Unreleased

* Fail Apple builds before native symbol lookup when the resolved llama.cpp
companion does not match the core native runtime, with an actionable upgrade
diagnostic instead of allowing ABI-incompatible frameworks.

* Updated the default llama.cpp native runtime pin to
`leehack/llamadart-native@v0.4.0`, regenerated matching Dart FFI bindings, refreshed
the `llamadart_llama_cpp_flutter` Apple SwiftPM checksum, and
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ The development example below requires both path overrides to the same checkout.
Publish companion `0.0.18` only with the matching next core release, and replace
these temporary overrides/version constraints during that coordinated release.

Apple builds verify the resolved companion's SwiftPM runtime pin before native
symbol lookup. Incompatible companions or unverified local `Artifacts`
overrides fail the build; resolve the matching companion and rerun
`flutter pub get`. Core native overrides do not replace SPM frameworks.

```yaml
dependencies:
llamadart: ^0.8.22
Expand Down
188 changes: 149 additions & 39 deletions hook/build.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'dart:async'
show Completer, StreamSubscription, Timer, TimeoutException, unawaited;
import 'dart:convert' show utf8;
import 'dart:convert' show jsonDecode, utf8;
import 'dart:io';

import 'package:archive/archive.dart';
Expand All @@ -10,6 +10,7 @@ import 'package:hooks/hooks.dart';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'package:yaml/yaml.dart';

import 'package:llamadart/src/hook/native_bundle_config.dart';

Expand All @@ -18,6 +19,11 @@ const _nativeRepoSlug = 'leehack/llamadart-native';

const _packageName = 'llamadart';
const _llamaCppFlutterPackageName = 'llamadart_llama_cpp_flutter';
// Bind the maintained SwiftPM code, not just a tag declaration that arbitrary
// Swift could ignore. Only the release tag/checksum and CRLF are normalized.
// Changes to the companion manifest implementation require contract review.
const _appleCompanionManifestTemplateSha256 =
'6f047f32a768fb3afd2eb4b0488768b591f254fe94e2bb472c5d70b34ff52a86';
const _liteRtLmFlutterPackageName = 'llamadart_litert_lm_flutter';
const _thirdPartyDir = 'third_party';
const _binDir = 'bin';
Expand Down Expand Up @@ -254,6 +260,7 @@ void main(List<String> args) async {
final appleSpmRuntimes = _flutterAppleCompanionRuntimes(
input: input,
code: code,
output: output,
log: log,
);
var selectedRuntimes =
Expand Down Expand Up @@ -520,6 +527,7 @@ bool _isAppleTarget(OS os) => os == OS.iOS || os == OS.macOS;
List<String>? _flutterAppleCompanionRuntimes({
required BuildInput input,
required CodeConfig code,
required BuildOutputBuilder output,
required Logger log,
}) {
if (!_isAppleTarget(code.targetOS)) {
Expand Down Expand Up @@ -551,6 +559,7 @@ List<String>? _flutterAppleCompanionRuntimes({
}

final pubspecSource = pubspec.readAsStringSync();
output.dependencies.add(pubspec.uri);
final isFlutter = _pubspecDeclaresFlutter(pubspecSource);
if (!isFlutter) {
log.info(
Expand All @@ -563,6 +572,7 @@ List<String>? _flutterAppleCompanionRuntimes({
final dependencies = _pubspecDependencyNames(pubspecSource);
final runtimes = <String>[];
if (dependencies.contains(_llamaCppFlutterPackageName)) {
_validateAppleLlamaCompanion(consumerRoot, output);
runtimes.add(nativeRuntimeLlamaCpp);
}
if (dependencies.contains(_liteRtLmFlutterPackageName)) {
Expand All @@ -579,6 +589,126 @@ List<String>? _flutterAppleCompanionRuntimes({
return runtimes;
}

void _validateAppleLlamaCompanion(
Directory consumerRoot,
BuildOutputBuilder output,
) {
Never reject(String reason) => throw StateError(
'Incompatible Apple llama.cpp companion: $reason '
'Resolve $_llamaCppFlutterPackageName with a Package.swift pin matching '
'$_nativeRepoSlug@$_llamaCppTag and rerun flutter pub get. '
'Upgrade the core and companion together to a matching released pair; '
'native tag/path overrides do not replace SPM frameworks. '
'No in-process native asset was emitted.',
);

try {
var directory = consumerRoot;
File? configuration;
while (true) {
final candidate = File(
path.join(directory.path, _dartToolDir, 'package_config.json'),
);
if (candidate.existsSync()) {
configuration = candidate;
break;
}
final parent = directory.parent;
if (_sameDirectory(parent, directory)) break;
directory = parent;
}
if (configuration == null) {
reject('Resolved package configuration missing.');
}
output.dependencies.add(configuration.uri);
final config = jsonDecode(configuration.readAsStringSync());
if (config is! Map ||
config['configVersion'] != 2 ||
config['packages'] is! List) {
reject('Resolved package configuration is malformed.');
}
final entries = config['packages'] as List;
if (entries.any((entry) => entry is! Map)) {
reject('Resolved package configuration contains malformed entries.');
}
final companions = entries
.where((entry) => (entry as Map)['name'] == _llamaCppFlutterPackageName)
.toList();
if (companions.length != 1) {
reject('Expected exactly one resolved llama.cpp companion.');
}
final root = (companions.single as Map)['rootUri'];
if (root is! String || root.isEmpty) reject('Companion root URI missing.');
final uri = configuration.uri.resolve(root);
if (uri.scheme != 'file' || uri.hasQuery || uri.hasFragment) {
reject('Companion root must be a local package directory.');
}
final companionRoot = Directory.fromUri(uri);
final pubspec = File(path.join(companionRoot.path, 'pubspec.yaml'));
final manifest = File(
path.join(
companionRoot.path,
'darwin',
_llamaCppFlutterPackageName,
'Package.swift',
),
);
output.dependencies.addAll([pubspec.uri, manifest.uri]);
if (!pubspec.existsSync() || !manifest.existsSync()) {
reject('Resolved companion package metadata is missing.');
}
final metadata = loadYaml(pubspec.readAsStringSync());
if (metadata is! Map ||
metadata['name'] != _llamaCppFlutterPackageName ||
metadata['version'] is! String ||
!RegExp(
r'^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$',
).hasMatch(metadata['version'] as String)) {
reject('Resolved companion identity is malformed.');
}
final source = manifest.readAsStringSync();
final pins = RegExp(
r'^let llamaCppTag = "([^"\r\n]+)"\s*$',
multiLine: true,
).allMatches(source).toList();
if (pins.length != 1 || pins.single.group(1) != _llamaCppTag) {
reject(
'Resolved companion ${metadata['version']} does not uniquely pin '
'the required native runtime $_llamaCppTag.',
);
}
final normalizedSource = source.replaceAll('\r\n', '\n');
final checksum = RegExp(r'checksum: "[0-9a-f]{64}"');
final template = normalizedSource
.replaceFirst(
RegExp(r'^let llamaCppTag = "[^"\r\n]+"$', multiLine: true),
'let llamaCppTag = "PIN"',
)
.replaceFirst(checksum, 'checksum: "CHECKSUM"');
if (checksum.allMatches(normalizedSource).length != 1 ||
sha256.convert(utf8.encode(template)).toString() !=
_appleCompanionManifestTemplateSha256) {
reject('Companion SwiftPM target does not use the supported native pin.');
}
final artifacts = Directory(path.join(manifest.parent.path, 'Artifacts'));
// The supported manifest can prefer local binaries over its remote pin.
// Such binaries have no verified ABI contract and must not inherit trust
// from the tag string. Track the directory so local additions invalidate
// an otherwise cached successful hook result.
output.dependencies.add(artifacts.uri);
if (artifacts.existsSync()) {
reject(
'Local Artifacts overrides cannot establish framework ABI '
'compatibility; remove them and use the pinned framework.',
);
}
} on FileSystemException {
reject('Unable to read resolved companion metadata.');
} on FormatException {
reject('Resolved companion metadata is malformed.');
}
}

Directory? _consumerPackageRoot(BuildInput input) {
final fromUserDefines = _consumerPackageRootFromUserDefines(input);
if (fromUserDefines != null) {
Expand Down Expand Up @@ -652,49 +782,29 @@ bool _sameDirectory(Directory a, Directory b) {
}

bool _pubspecDeclaresFlutter(String source) {
final lines = source.split('\n');
for (final rawLine in lines) {
final line = rawLine.split('#').first;
if (RegExp(r'^\s*sdk\s*:\s*flutter\s*$').hasMatch(line)) {
return true;
}
}
return false;
final pubspec = _readConsumerPubspec(source);
if (pubspec is! Map) return false;
final dependencies = pubspec['dependencies'];
if (dependencies is! Map) return false;
final flutter = dependencies['flutter'];
return flutter is Map && flutter['sdk'] == 'flutter';
}

Set<String> _pubspecDependencyNames(String source) {
final dependencies = <String>{};
String? section;
int? dependencyIndent;
for (final rawLine in source.split('\n')) {
final line = rawLine.split('#').first;
if (line.trim().isEmpty) {
continue;
}

final topLevel = RegExp(r'^([A-Za-z_][A-Za-z0-9_]*)\s*:').firstMatch(line);
if (topLevel != null) {
section = topLevel.group(1);
dependencyIndent = null;
continue;
}

if (section != 'dependencies') {
continue;
}
final pubspec = _readConsumerPubspec(source);
if (pubspec is! Map || pubspec['dependencies'] is! Map) return {};
return (pubspec['dependencies'] as Map).keys.whereType<String>().toSet();
}

final dependency = RegExp(
r'^(\s+)([A-Za-z_][A-Za-z0-9_]*)\s*:',
).firstMatch(line);
if (dependency != null) {
final indent = dependency.group(1)!.length;
dependencyIndent ??= indent;
if (indent == dependencyIndent) {
dependencies.add(dependency.group(2)!);
}
}
Object? _readConsumerPubspec(String source) {
try {
return loadYaml(source);
} on FormatException {
throw StateError(
'Cannot validate Apple runtime selection: malformed '
'consumer pubspec. Fix pubspec.yaml and rerun flutter pub get.',
);
}
return dependencies;
}

Future<void> _emitLiteRtLmAssets({
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dependencies:
code_assets: '>=1.0.0 <3.0.0'
hooks: '>=1.0.0 <3.0.0'
logging: ^1.3.0
yaml: ^3.1.3
# jinja_analyzer.dart imports dinja's private src/lexer.dart, src/parser.dart
# and src/ast/nodes.dart. This bound only rules out a silently resolved 1.1.x;
# a 1.0.x patch can move those files just as freely, and consumers of released
Expand Down
Loading
Loading