Files
flutter_lua_runtime/lib/runtime/packages/game_package_repository.dart

455 lines
13 KiB
Dart

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 FileGamePackageRepository implements GamePackageRepository {
const FileGamePackageRepository({
required this.baseDirectory,
this.runtimeOptions = const RuntimeOptions(),
});
final String baseDirectory;
final RuntimeOptions runtimeOptions;
@override
Future<GamePackage> load(String gameId) async {
final root = Directory(p.join(baseDirectory, gameId));
final manifestFile = File(p.join(root.path, 'manifest.json'));
if (!manifestFile.existsSync()) {
throw FileSystemException(
'Game package manifest not found',
manifestFile.path,
);
}
return GamePackage.file(
rootPath: root.path,
manifest: GamePackageManifest.fromJsonString(
await manifestFile.readAsString(),
),
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');
}
remoteManifest.compatibility.verify(
runtimeApiVersion: runtimeApiVersion,
runtimeOptions: runtimeOptions,
);
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 = _remoteManifestUri(gameId);
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?>,
);
}
Uri _remoteManifestUri(String gameId) {
final uri = baseUri.resolve('$gameId/remote_manifest.json');
final query = Map<String, String>.from(uri.queryParameters)
..addAll({
'runtimeApiVersion': runtimeApiVersion.toString(),
'runtimeVersion': runtimeOptions.runtimeVersion,
'hostBuild': runtimeOptions.hostBuild.toString(),
'platform': _platformName(runtimeOptions.platform),
'channel': runtimeOptions.channel,
});
return uri.replace(queryParameters: query);
}
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,
this.compatibility = const RemotePackageCompatibility(),
});
final String gameId;
final String version;
final Uri packageUrl;
final String sha256;
final RemotePackageCompatibility compatibility;
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'),
compatibility: RemotePackageCompatibility.fromMap(map['compat']),
);
}
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');
}
}
class RemotePackageCompatibility {
const RemotePackageCompatibility({
this.runtimeApiVersion,
this.minRuntimeVersion,
this.maxRuntimeVersion,
this.minHostBuild,
this.maxHostBuild,
this.platforms = const [],
this.channels = const [],
});
final int? runtimeApiVersion;
final String? minRuntimeVersion;
final String? maxRuntimeVersion;
final int? minHostBuild;
final int? maxHostBuild;
final List<String> platforms;
final List<String> channels;
static RemotePackageCompatibility fromMap(Object? value) {
if (value == null) {
return const RemotePackageCompatibility();
}
if (value is! Map) {
throw const FormatException('remote_manifest.compat must be a map');
}
final map = Map<String, Object?>.from(value);
return RemotePackageCompatibility(
runtimeApiVersion: _optionalInt(map, 'runtimeApiVersion'),
minRuntimeVersion: _optionalString(map, 'minRuntimeVersion'),
maxRuntimeVersion: _optionalString(map, 'maxRuntimeVersion'),
minHostBuild: _optionalInt(map, 'minHostBuild'),
maxHostBuild: _optionalInt(map, 'maxHostBuild'),
platforms: _optionalStringList(map, 'platforms'),
channels: _optionalStringList(map, 'channels'),
);
}
void verify({
required int runtimeApiVersion,
required RuntimeOptions runtimeOptions,
}) {
if (this.runtimeApiVersion != null &&
this.runtimeApiVersion != runtimeApiVersion) {
throw const FormatException('Remote package runtimeApiVersion mismatch');
}
if (minRuntimeVersion != null &&
_compareVersions(runtimeOptions.runtimeVersion, minRuntimeVersion!) <
0) {
throw const FormatException('Remote package requires newer runtime');
}
if (maxRuntimeVersion != null &&
_compareVersions(runtimeOptions.runtimeVersion, maxRuntimeVersion!) >
0) {
throw const FormatException(
'Remote package does not support this runtime',
);
}
if (minHostBuild != null && runtimeOptions.hostBuild < minHostBuild!) {
throw const FormatException('Remote package requires newer host build');
}
if (maxHostBuild != null && runtimeOptions.hostBuild > maxHostBuild!) {
throw const FormatException(
'Remote package does not support this host build',
);
}
final platform = _platformName(runtimeOptions.platform);
if (platforms.isNotEmpty && !platforms.contains(platform)) {
throw const FormatException(
'Remote package does not support this platform',
);
}
if (channels.isNotEmpty && !channels.contains(runtimeOptions.channel)) {
throw const FormatException(
'Remote package does not support this channel',
);
}
}
static String? _optionalString(Map<String, Object?> map, String key) {
final value = map[key];
if (value == null) {
return null;
}
if (value is String && value.isNotEmpty) {
return value;
}
throw FormatException('remote_manifest.compat.$key must be a string');
}
static int? _optionalInt(Map<String, Object?> map, String key) {
final value = map[key];
if (value == null) {
return null;
}
if (value is num) {
return value.toInt();
}
throw FormatException('remote_manifest.compat.$key must be an integer');
}
static List<String> _optionalStringList(
Map<String, Object?> map,
String key,
) {
final value = map[key];
if (value == null) {
return const [];
}
if (value is! List) {
throw FormatException(
'remote_manifest.compat.$key must be a string list',
);
}
return value
.map((item) {
if (item is String && item.isNotEmpty) {
return item;
}
throw FormatException(
'remote_manifest.compat.$key must be a string list',
);
})
.toList(growable: false);
}
}
String _platformName(String? explicitPlatform) {
if (explicitPlatform != null && explicitPlatform.isNotEmpty) {
return explicitPlatform;
}
if (Platform.isAndroid) {
return 'android';
}
if (Platform.isIOS) {
return 'ios';
}
if (Platform.isMacOS) {
return 'macos';
}
if (Platform.isWindows) {
return 'windows';
}
if (Platform.isLinux) {
return 'linux';
}
if (Platform.isFuchsia) {
return 'fuchsia';
}
return 'unknown';
}
int _compareVersions(String left, String right) {
final leftParts = _versionParts(left);
final rightParts = _versionParts(right);
final count = leftParts.length > rightParts.length
? leftParts.length
: rightParts.length;
for (var index = 0; index < count; index++) {
final leftValue = index < leftParts.length ? leftParts[index] : 0;
final rightValue = index < rightParts.length ? rightParts[index] : 0;
if (leftValue != rightValue) {
return leftValue.compareTo(rightValue);
}
}
return 0;
}
List<int> _versionParts(String value) {
final normalized = value.split('-').first;
return normalized
.split('.')
.map((part) {
final digits = RegExp(r'^\d+').firstMatch(part)?.group(0);
return int.tryParse(digits ?? '') ?? 0;
})
.toList(growable: false);
}