feat: add package source compatibility controls
This commit is contained in:
@@ -38,6 +38,35 @@ class AssetGamePackageRepository implements GamePackageRepository {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -103,6 +132,10 @@ class RemoteGamePackageRepository implements GamePackageRepository {
|
||||
if (remoteManifest.gameId != gameId) {
|
||||
throw const FormatException('Remote manifest gameId mismatch');
|
||||
}
|
||||
remoteManifest.compatibility.verify(
|
||||
runtimeApiVersion: runtimeApiVersion,
|
||||
runtimeOptions: runtimeOptions,
|
||||
);
|
||||
|
||||
final packageRoot = await _downloadAndExtract(
|
||||
client,
|
||||
@@ -130,7 +163,7 @@ class RemoteGamePackageRepository implements GamePackageRepository {
|
||||
http.Client client,
|
||||
String gameId,
|
||||
) async {
|
||||
final uri = baseUri.resolve('$gameId/remote_manifest.json');
|
||||
final uri = _remoteManifestUri(gameId);
|
||||
final response = await client.get(uri);
|
||||
if (response.statusCode != 200) {
|
||||
throw HttpException(
|
||||
@@ -143,6 +176,19 @@ class RemoteGamePackageRepository implements GamePackageRepository {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -200,12 +246,14 @@ class RemotePackageManifest {
|
||||
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(
|
||||
@@ -213,6 +261,7 @@ class RemotePackageManifest {
|
||||
version: _string(map, 'version'),
|
||||
packageUrl: Uri.parse(_string(map, 'packageUrl')),
|
||||
sha256: _string(map, 'sha256'),
|
||||
compatibility: RemotePackageCompatibility.fromMap(map['compat']),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -224,3 +273,182 @@ class RemotePackageManifest {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user