feat: add package source compatibility controls

This commit is contained in:
gem
2026-06-10 17:54:12 +08:00
parent 6608d0a975
commit 79ee35db2f
12 changed files with 611 additions and 30 deletions

View File

@@ -16,9 +16,11 @@ export 'runtime/host/runtime_host_bridge.dart'
export 'runtime/packages/game_package_repository.dart'
show
AssetGamePackageRepository,
FileGamePackageRepository,
GamePackageRepository,
RemoteGamePackageRepository;
export 'runtime/scripting/lua_dardo_script_engine.dart'
show LuaDardoScriptEngine;
export 'runtime/scripting/script_engine.dart' show ScriptEngine;
export 'runtime/storage/runtime_storage_manager.dart' show RuntimeStorageManager;
export 'runtime/storage/runtime_storage_manager.dart'
show RuntimeStorageManager;

View File

@@ -2,6 +2,10 @@ class RuntimeOptions {
const RuntimeOptions({
this.runtimeLuaRoot = defaultRuntimeLuaRoot,
this.basePackages = const [],
this.runtimeVersion = '0.0.0',
this.hostBuild = 0,
this.channel = 'dev',
this.platform,
});
static const defaultRuntimeLuaRoot = 'assets/runtime/lua';
@@ -11,4 +15,17 @@ class RuntimeOptions {
// 框架包 gameId 列表,按顺序先于游戏包加载。
// 后加载的同名模块覆盖先加载的。
final List<String> basePackages;
// 宿主 Flutter App 当前集成的 Lua Runtime 版本。
// 远程包可通过 minRuntimeVersion/maxRuntimeVersion 限制兼容范围。
final String runtimeVersion;
// 宿主 App 构建号。远程包可通过 minHostBuild/maxHostBuild 避免旧 App 拉取新包。
final int hostBuild;
// dev/staging/prod 等发布通道,用于服务器和客户端双重筛选远程包。
final String channel;
// 平台名,例如 windows/android/ios。为空时 Runtime 会尝试自动识别。
final String? platform;
}

View File

@@ -150,10 +150,7 @@ class PackageActivationController {
// 加载 base packages框架包按 runtimeOptions.basePackages 顺序。
final basePackages = <GamePackage>[];
for (final baseId in runtimeOptions.basePackages) {
final baseCandidates = await _candidatePackages(
baseId,
shouldContinue,
);
final baseCandidates = await _candidatePackages(baseId, shouldContinue);
for (final baseCandidate in baseCandidates) {
try {
await verifier.verify(baseCandidate);

View File

@@ -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);
}

View File

@@ -32,34 +32,31 @@ class StablePackageStore {
return;
}
final marker = await _markerFile(package.manifest.gameId);
marker.createSync(recursive: true);
marker.parent.createSync(recursive: true);
final previous = await stablePackage(package.manifest.gameId);
final data = {
'current': package.rootPath,
if (previous != null && previous.rootPath != package.rootPath)
'previous': previous.rootPath,
};
marker.writeAsStringSync(const JsonEncoder.withIndent(' ').convert(data));
final temporary = File('${marker.path}.tmp');
temporary.writeAsStringSync(
const JsonEncoder.withIndent(' ').convert(data),
);
if (marker.existsSync()) {
marker.deleteSync();
}
temporary.renameSync(marker.path);
}
Future<GamePackage?> stablePackage(String gameId) async {
final marker = await _markerFile(gameId);
if (!marker.existsSync()) {
return null;
}
final data =
jsonDecode(await marker.readAsString()) as Map<String, Object?>;
return _packageFromPath(data['current']);
final data = await _readMarker(gameId);
return _packageFromPath(data?['current']);
}
Future<GamePackage?> previousStablePackage(String gameId) async {
final marker = await _markerFile(gameId);
if (!marker.existsSync()) {
return null;
}
final data =
jsonDecode(await marker.readAsString()) as Map<String, Object?>;
return _packageFromPath(data['previous']);
final data = await _readMarker(gameId);
return _packageFromPath(data?['previous']);
}
Future<File> _markerFile(String gameId) async {
@@ -67,6 +64,26 @@ class StablePackageStore {
return File(p.join(root.path, gameId, 'stable.json'));
}
Future<Map<String, Object?>?> _readMarker(String gameId) async {
final marker = await _markerFile(gameId);
if (!marker.existsSync()) {
return null;
}
final source = await marker.readAsString();
if (source.trim().isEmpty) {
return null;
}
try {
final value = jsonDecode(source);
if (value is Map) {
return Map<String, Object?>.from(value);
}
} catch (_) {
return null;
}
return null;
}
GamePackage? _packageFromPath(Object? pathValue) {
if (pathValue is! String || pathValue.isEmpty) {
return null;

View File

@@ -289,7 +289,9 @@ class LuaDardoScriptEngine implements ScriptEngine {
final storage = _requireStorage();
final key = lua.toStr(1);
if (key == null || key.isEmpty) {
throw const FormatException('runtime.storage_get(key, defaultValue) requires key');
throw const FormatException(
'runtime.storage_get(key, defaultValue) requires key',
);
}
final defaultValue = _readValue(2);
_pushValue(storage.getValue(key, defaultValue));
@@ -300,7 +302,9 @@ class LuaDardoScriptEngine implements ScriptEngine {
final storage = _requireStorage();
final key = lua.toStr(1);
if (key == null || key.isEmpty) {
throw const FormatException('runtime.storage_set(key, value) requires key');
throw const FormatException(
'runtime.storage_set(key, value) requires key',
);
}
final value = _readValue(2);
lua.pushBoolean(storage.setValue(key, value));

View File

@@ -25,10 +25,7 @@ class RuntimeStorageManager {
try {
final raw = jsonDecode(file.readAsStringSync());
if (raw is Map) {
return RuntimeStorageManager._(
file,
Map<String, Object?>.from(raw),
);
return RuntimeStorageManager._(file, Map<String, Object?>.from(raw));
}
} catch (_) {
// Corrupt storage should not prevent a game from loading.
@@ -78,7 +75,8 @@ class RuntimeStorageManager {
}
if (value is Map) {
return {
for (final entry in value.entries) entry.key.toString(): _normalize(entry.value),
for (final entry in value.entries)
entry.key.toString(): _normalize(entry.value),
};
}
return value.toString();