Initial flame_lua_runtime package
This commit is contained in:
226
lib/runtime/packages/game_package_repository.dart
Normal file
226
lib/runtime/packages/game_package_repository.dart
Normal file
@@ -0,0 +1,226 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../game/runtime_options.dart';
|
||||
import 'game_package.dart';
|
||||
import 'game_package_manifest.dart';
|
||||
import 'package_verifier.dart';
|
||||
import 'stable_package_store.dart';
|
||||
|
||||
abstract interface class GamePackageRepository {
|
||||
Future<GamePackage> load(String gameId);
|
||||
}
|
||||
|
||||
class AssetGamePackageRepository implements GamePackageRepository {
|
||||
const AssetGamePackageRepository({
|
||||
this.basePath = 'assets/games',
|
||||
this.runtimeOptions = const RuntimeOptions(),
|
||||
});
|
||||
|
||||
final String basePath;
|
||||
final RuntimeOptions runtimeOptions;
|
||||
|
||||
@override
|
||||
Future<GamePackage> load(String gameId) async {
|
||||
final root = '$basePath/$gameId';
|
||||
final source = await rootBundle.loadString('$root/manifest.json');
|
||||
return GamePackage.asset(
|
||||
rootPath: root,
|
||||
manifest: GamePackageManifest.fromJsonString(source),
|
||||
runtimeLuaRoot: runtimeOptions.runtimeLuaRoot,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteGamePackageRepository implements GamePackageRepository {
|
||||
RemoteGamePackageRepository({
|
||||
required this.baseUri,
|
||||
this.runtimeApiVersion = 1,
|
||||
this.runtimeOptions = const RuntimeOptions(),
|
||||
GamePackageRepository? fallback,
|
||||
StablePackageStore? store,
|
||||
http.Client? client,
|
||||
}) : fallback =
|
||||
fallback ??
|
||||
AssetGamePackageRepository(runtimeOptions: runtimeOptions),
|
||||
store = store ?? StablePackageStore(runtimeOptions: runtimeOptions),
|
||||
_client = client;
|
||||
|
||||
final Uri baseUri;
|
||||
final int runtimeApiVersion;
|
||||
final RuntimeOptions runtimeOptions;
|
||||
final GamePackageRepository fallback;
|
||||
final StablePackageStore store;
|
||||
final http.Client? _client;
|
||||
|
||||
@override
|
||||
Future<GamePackage> load(String gameId) async {
|
||||
final verifier = PackageVerifier(runtimeApiVersion: runtimeApiVersion);
|
||||
final client = _client ?? http.Client();
|
||||
final shouldCloseClient = _client == null;
|
||||
|
||||
try {
|
||||
final package = await _loadRemoteCandidate(client, gameId);
|
||||
await verifier.verify(package);
|
||||
return package;
|
||||
} catch (_) {
|
||||
final stable = await store.stablePackage(gameId);
|
||||
if (stable != null) {
|
||||
try {
|
||||
await verifier.verify(stable);
|
||||
return stable;
|
||||
} catch (_) {
|
||||
final previous = await store.previousStablePackage(gameId);
|
||||
if (previous != null) {
|
||||
try {
|
||||
await verifier.verify(previous);
|
||||
return previous;
|
||||
} catch (_) {
|
||||
// Fall through to bundled fallback.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback.load(gameId);
|
||||
} finally {
|
||||
if (shouldCloseClient) {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<GamePackage> _loadRemoteCandidate(
|
||||
http.Client client,
|
||||
String gameId,
|
||||
) async {
|
||||
final remoteManifest = await _fetchRemoteManifest(client, gameId);
|
||||
if (remoteManifest.gameId != gameId) {
|
||||
throw const FormatException('Remote manifest gameId mismatch');
|
||||
}
|
||||
|
||||
final packageRoot = await _downloadAndExtract(
|
||||
client,
|
||||
gameId,
|
||||
remoteManifest,
|
||||
);
|
||||
final manifestFile = File(p.join(packageRoot.path, 'manifest.json'));
|
||||
final packageManifest = GamePackageManifest.fromJsonString(
|
||||
await manifestFile.readAsString(),
|
||||
);
|
||||
if (packageManifest.gameId != gameId) {
|
||||
throw const FormatException('Package manifest gameId mismatch');
|
||||
}
|
||||
if (packageManifest.version != remoteManifest.version) {
|
||||
throw const FormatException('Package manifest version mismatch');
|
||||
}
|
||||
return GamePackage.file(
|
||||
rootPath: packageRoot.path,
|
||||
manifest: packageManifest,
|
||||
runtimeLuaRoot: runtimeOptions.runtimeLuaRoot,
|
||||
);
|
||||
}
|
||||
|
||||
Future<RemotePackageManifest> _fetchRemoteManifest(
|
||||
http.Client client,
|
||||
String gameId,
|
||||
) async {
|
||||
final uri = baseUri.resolve('$gameId/remote_manifest.json');
|
||||
final response = await client.get(uri);
|
||||
if (response.statusCode != 200) {
|
||||
throw HttpException(
|
||||
'Remote manifest failed: ${response.statusCode}',
|
||||
uri: uri,
|
||||
);
|
||||
}
|
||||
return RemotePackageManifest.fromMap(
|
||||
jsonDecode(response.body) as Map<String, Object?>,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Directory> _downloadAndExtract(
|
||||
http.Client client,
|
||||
String gameId,
|
||||
RemotePackageManifest manifest,
|
||||
) async {
|
||||
final packageBytes = await _downloadPackage(client, manifest.packageUrl);
|
||||
_verifySha256(packageBytes, manifest.sha256);
|
||||
|
||||
final packageRoot = await store.versionDirectory(gameId, manifest.version);
|
||||
if (packageRoot.existsSync()) {
|
||||
packageRoot.deleteSync(recursive: true);
|
||||
}
|
||||
packageRoot.createSync(recursive: true);
|
||||
|
||||
final archive = ZipDecoder().decodeBytes(packageBytes);
|
||||
for (final file in archive.files) {
|
||||
final targetPath = p.normalize(p.join(packageRoot.path, file.name));
|
||||
if (!p.isWithin(packageRoot.path, targetPath) &&
|
||||
targetPath != packageRoot.path) {
|
||||
throw const FormatException('Unsafe zip entry path');
|
||||
}
|
||||
if (file.isFile) {
|
||||
File(targetPath)
|
||||
..createSync(recursive: true)
|
||||
..writeAsBytesSync(file.content as List<int>);
|
||||
} else {
|
||||
Directory(targetPath).createSync(recursive: true);
|
||||
}
|
||||
}
|
||||
return packageRoot;
|
||||
}
|
||||
|
||||
Future<List<int>> _downloadPackage(http.Client client, Uri uri) async {
|
||||
final response = await client.get(uri);
|
||||
if (response.statusCode != 200) {
|
||||
throw HttpException(
|
||||
'Package download failed: ${response.statusCode}',
|
||||
uri: uri,
|
||||
);
|
||||
}
|
||||
return response.bodyBytes;
|
||||
}
|
||||
|
||||
void _verifySha256(List<int> bytes, String expected) {
|
||||
final actual = sha256.convert(bytes).toString();
|
||||
if (actual != expected) {
|
||||
throw const FormatException('Package sha256 mismatch');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RemotePackageManifest {
|
||||
const RemotePackageManifest({
|
||||
required this.gameId,
|
||||
required this.version,
|
||||
required this.packageUrl,
|
||||
required this.sha256,
|
||||
});
|
||||
|
||||
final String gameId;
|
||||
final String version;
|
||||
final Uri packageUrl;
|
||||
final String sha256;
|
||||
|
||||
static RemotePackageManifest fromMap(Map<String, Object?> map) {
|
||||
return RemotePackageManifest(
|
||||
gameId: _string(map, 'gameId'),
|
||||
version: _string(map, 'version'),
|
||||
packageUrl: Uri.parse(_string(map, 'packageUrl')),
|
||||
sha256: _string(map, 'sha256'),
|
||||
);
|
||||
}
|
||||
|
||||
static String _string(Map<String, Object?> map, String key) {
|
||||
final value = map[key];
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
throw FormatException('remote_manifest.$key must be a non-empty string');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user