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 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 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 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 _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 _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, ); } Future _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); } else { Directory(targetPath).createSync(recursive: true); } } return packageRoot; } Future> _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 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 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 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'); } }