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 FileGamePackageRepository implements GamePackageRepository { const FileGamePackageRepository({ required this.baseDirectory, this.runtimeOptions = const RuntimeOptions(), }); final String baseDirectory; final RuntimeOptions runtimeOptions; @override Future 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 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'); } 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 _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, ); } Uri _remoteManifestUri(String gameId) { final uri = baseUri.resolve('$gameId/remote_manifest.json'); final query = Map.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 _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, this.compatibility = const RemotePackageCompatibility(), }); final String gameId; final String version; final Uri packageUrl; final String sha256; final RemotePackageCompatibility compatibility; 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'), compatibility: RemotePackageCompatibility.fromMap(map['compat']), ); } 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'); } } 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 platforms; final List 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.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 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 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 _optionalStringList( Map 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 _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); }