Initial flame_lua_runtime package
This commit is contained in:
96
lib/runtime/packages/game_package.dart
Normal file
96
lib/runtime/packages/game_package.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../game/runtime_options.dart';
|
||||
import 'game_package_manifest.dart';
|
||||
|
||||
class GamePackage {
|
||||
const GamePackage.asset({
|
||||
required this.rootPath,
|
||||
required this.manifest,
|
||||
this.runtimeLuaRoot = RuntimeOptions.defaultRuntimeLuaRoot,
|
||||
}) : source = GamePackageSource.asset;
|
||||
|
||||
static const runtimeLuaPrefix = 'runtime:';
|
||||
|
||||
const GamePackage.file({
|
||||
required this.rootPath,
|
||||
required this.manifest,
|
||||
this.runtimeLuaRoot = RuntimeOptions.defaultRuntimeLuaRoot,
|
||||
}) : source = GamePackageSource.file;
|
||||
|
||||
final String rootPath;
|
||||
final GamePackageSource source;
|
||||
final GamePackageManifest manifest;
|
||||
final String runtimeLuaRoot;
|
||||
|
||||
String get entryPath => _join(rootPath, manifest.entry);
|
||||
|
||||
bool get isAsset => source == GamePackageSource.asset;
|
||||
|
||||
Future<String> readText(String relativeOrAbsolutePath) async {
|
||||
final runtimePath = _resolveRuntimeLuaPath(relativeOrAbsolutePath);
|
||||
if (runtimePath != null) {
|
||||
return rootBundle.loadString(runtimePath);
|
||||
}
|
||||
|
||||
final path = _resolvePackagePath(relativeOrAbsolutePath);
|
||||
if (isAsset) {
|
||||
return rootBundle.loadString(path);
|
||||
}
|
||||
return File(path).readAsString();
|
||||
}
|
||||
|
||||
Future<ByteData> readBytes(String relativeOrAbsolutePath) async {
|
||||
final path = _resolvePackagePath(relativeOrAbsolutePath);
|
||||
if (isAsset) {
|
||||
return rootBundle.load(path);
|
||||
}
|
||||
final bytes = await File(path).readAsBytes();
|
||||
return ByteData.sublistView(bytes);
|
||||
}
|
||||
|
||||
String resolveResourcePath(String keyOrPath) {
|
||||
final resource = manifest.resources[keyOrPath];
|
||||
if (resource != null) {
|
||||
return _join(rootPath, resource.path);
|
||||
}
|
||||
|
||||
if (keyOrPath.startsWith(rootPath)) {
|
||||
return keyOrPath;
|
||||
}
|
||||
if (keyOrPath.contains('/')) {
|
||||
return _join(rootPath, keyOrPath);
|
||||
}
|
||||
return _join(_join(rootPath, manifest.assetsBase), keyOrPath);
|
||||
}
|
||||
|
||||
String _resolvePackagePath(String relativeOrAbsolutePath) {
|
||||
if (relativeOrAbsolutePath.startsWith(rootPath)) {
|
||||
return relativeOrAbsolutePath;
|
||||
}
|
||||
return _join(rootPath, relativeOrAbsolutePath);
|
||||
}
|
||||
|
||||
String? _resolveRuntimeLuaPath(String path) {
|
||||
if (!path.startsWith(runtimeLuaPrefix)) {
|
||||
return null;
|
||||
}
|
||||
final name = path.substring(runtimeLuaPrefix.length);
|
||||
if (name.isEmpty || name.contains('/') || name.contains('..')) {
|
||||
throw FormatException('Invalid runtime Lua module path: $path');
|
||||
}
|
||||
return _join(runtimeLuaRoot, name);
|
||||
}
|
||||
|
||||
String _join(String left, String right) {
|
||||
final normalizedLeft = left.endsWith('/')
|
||||
? left.substring(0, left.length - 1)
|
||||
: left;
|
||||
final normalizedRight = right.startsWith('/') ? right.substring(1) : right;
|
||||
return '$normalizedLeft/$normalizedRight';
|
||||
}
|
||||
}
|
||||
|
||||
enum GamePackageSource { asset, file }
|
||||
231
lib/runtime/packages/game_package_activation_controller.dart
Normal file
231
lib/runtime/packages/game_package_activation_controller.dart
Normal file
@@ -0,0 +1,231 @@
|
||||
import '../audio/runtime_audio_manager.dart';
|
||||
import '../models/game_diff.dart';
|
||||
import '../resources/game_resource_manager.dart';
|
||||
import '../scripting/script_engine.dart';
|
||||
import 'game_package.dart';
|
||||
import 'game_package_repository.dart';
|
||||
import 'package_verifier.dart';
|
||||
import 'stable_package_store.dart';
|
||||
|
||||
class PackageActivationController {
|
||||
const PackageActivationController({
|
||||
required this.repository,
|
||||
required this.resources,
|
||||
required this.scriptEngine,
|
||||
this.audio,
|
||||
this.runtimeApiVersion = 1,
|
||||
this.store = const StablePackageStore(),
|
||||
this.assetFallback = const AssetGamePackageRepository(),
|
||||
this.resourceManagerFactory,
|
||||
this.audioManagerFactory,
|
||||
this.scriptEngineFactory,
|
||||
});
|
||||
|
||||
final GamePackageRepository repository;
|
||||
final GameResourceManager resources;
|
||||
final ScriptEngine scriptEngine;
|
||||
final RuntimeAudioManager? audio;
|
||||
final int runtimeApiVersion;
|
||||
final StablePackageStore store;
|
||||
final GamePackageRepository assetFallback;
|
||||
final GameResourceManager Function()? resourceManagerFactory;
|
||||
final RuntimeAudioManager Function()? audioManagerFactory;
|
||||
final ScriptEngine Function()? scriptEngineFactory;
|
||||
|
||||
Future<PackageActivationResult> activate({
|
||||
required String gameId,
|
||||
required Map<String, Object?> Function(GamePackage package) contextBuilder,
|
||||
bool Function()? shouldContinue,
|
||||
}) async {
|
||||
final plan = await prepare(
|
||||
gameId: gameId,
|
||||
contextBuilder: contextBuilder,
|
||||
shouldContinue: shouldContinue,
|
||||
);
|
||||
await commit(plan, shouldContinue: shouldContinue);
|
||||
return PackageActivationResult.fromPlan(plan);
|
||||
}
|
||||
|
||||
Future<PackageActivationPlan> prepare({
|
||||
required String gameId,
|
||||
required Map<String, Object?> Function(GamePackage package) contextBuilder,
|
||||
bool Function()? shouldContinue,
|
||||
}) async {
|
||||
final verifier = PackageVerifier(runtimeApiVersion: runtimeApiVersion);
|
||||
final candidates = await _candidatePackages(gameId, shouldContinue);
|
||||
|
||||
Object? lastError;
|
||||
for (final candidate in candidates) {
|
||||
try {
|
||||
_ensureContinue(shouldContinue);
|
||||
final plan = await _prepareCandidate(
|
||||
candidate: candidate,
|
||||
verifier: verifier,
|
||||
contextBuilder: contextBuilder,
|
||||
shouldContinue: shouldContinue,
|
||||
);
|
||||
return plan;
|
||||
} catch (error) {
|
||||
if (shouldContinue != null && !shouldContinue()) {
|
||||
rethrow;
|
||||
}
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
throw StateError(
|
||||
'No activatable package for $gameId. Last error: $lastError',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> commit(
|
||||
PackageActivationPlan plan, {
|
||||
bool Function()? shouldContinue,
|
||||
}) async {
|
||||
_ensureContinue(shouldContinue);
|
||||
await store.markStable(plan.package);
|
||||
_ensureContinue(shouldContinue);
|
||||
}
|
||||
|
||||
Future<List<GamePackage>> _candidatePackages(
|
||||
String gameId,
|
||||
bool Function()? shouldContinue,
|
||||
) async {
|
||||
final candidates = <GamePackage>[];
|
||||
|
||||
try {
|
||||
final package = await repository.load(gameId);
|
||||
_ensureContinue(shouldContinue);
|
||||
candidates.add(package);
|
||||
} catch (_) {
|
||||
// Continue with stable/fallback candidates.
|
||||
}
|
||||
_ensureContinue(shouldContinue);
|
||||
|
||||
final stable = await store.stablePackage(gameId);
|
||||
if (stable != null && !_containsPackage(candidates, stable)) {
|
||||
candidates.add(stable);
|
||||
}
|
||||
|
||||
_ensureContinue(shouldContinue);
|
||||
|
||||
final previous = await store.previousStablePackage(gameId);
|
||||
if (previous != null && !_containsPackage(candidates, previous)) {
|
||||
candidates.add(previous);
|
||||
}
|
||||
|
||||
_ensureContinue(shouldContinue);
|
||||
|
||||
final fallback = await assetFallback.load(gameId);
|
||||
if (!_containsPackage(candidates, fallback)) {
|
||||
candidates.add(fallback);
|
||||
}
|
||||
_ensureContinue(shouldContinue);
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
Future<PackageActivationPlan> _prepareCandidate({
|
||||
required GamePackage candidate,
|
||||
required PackageVerifier verifier,
|
||||
required Map<String, Object?> Function(GamePackage package) contextBuilder,
|
||||
required bool Function()? shouldContinue,
|
||||
}) async {
|
||||
final preparedResources = resourceManagerFactory?.call() ?? resources;
|
||||
final preparedAudio = audioManagerFactory?.call() ?? audio;
|
||||
final preparedScriptEngine = scriptEngineFactory?.call() ?? scriptEngine;
|
||||
final ownsPreparedResources = preparedResources != resources;
|
||||
final ownsPreparedAudio = preparedAudio != null && preparedAudio != audio;
|
||||
|
||||
try {
|
||||
await verifier.verify(candidate);
|
||||
_ensureContinue(shouldContinue);
|
||||
await preparedResources.mount(candidate);
|
||||
_ensureContinue(shouldContinue);
|
||||
await preparedAudio?.mount(candidate);
|
||||
_ensureContinue(shouldContinue);
|
||||
await preparedScriptEngine.loadPackage(candidate);
|
||||
_ensureContinue(shouldContinue);
|
||||
|
||||
final context = contextBuilder(candidate);
|
||||
_ensureContinue(shouldContinue);
|
||||
if (!preparedScriptEngine.smokeTest(context)) {
|
||||
throw StateError('Lua package smoke_test returned false');
|
||||
}
|
||||
|
||||
_ensureContinue(shouldContinue);
|
||||
final diff = preparedScriptEngine.init(context);
|
||||
_ensureContinue(shouldContinue);
|
||||
return PackageActivationPlan(
|
||||
package: candidate,
|
||||
initialDiff: diff,
|
||||
resources: preparedResources,
|
||||
scriptEngine: preparedScriptEngine,
|
||||
audio: preparedAudio,
|
||||
);
|
||||
} catch (_) {
|
||||
if (ownsPreparedResources) {
|
||||
preparedResources.dispose();
|
||||
}
|
||||
if (ownsPreparedAudio) {
|
||||
preparedAudio.dispose();
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _ensureContinue(bool Function()? shouldContinue) {
|
||||
if (shouldContinue != null && !shouldContinue()) {
|
||||
throw StateError('Package activation cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
bool _containsPackage(List<GamePackage> packages, GamePackage package) {
|
||||
return packages.any(
|
||||
(item) =>
|
||||
item.source == package.source && item.rootPath == package.rootPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PackageActivationPlan {
|
||||
const PackageActivationPlan({
|
||||
required this.package,
|
||||
required this.initialDiff,
|
||||
required this.resources,
|
||||
required this.scriptEngine,
|
||||
this.audio,
|
||||
});
|
||||
|
||||
final GamePackage package;
|
||||
final GameDiff initialDiff;
|
||||
final GameResourceManager resources;
|
||||
final ScriptEngine scriptEngine;
|
||||
final RuntimeAudioManager? audio;
|
||||
}
|
||||
|
||||
class PackageActivationResult {
|
||||
const PackageActivationResult({
|
||||
required this.package,
|
||||
required this.initialDiff,
|
||||
required this.resources,
|
||||
required this.scriptEngine,
|
||||
this.audio,
|
||||
});
|
||||
|
||||
factory PackageActivationResult.fromPlan(PackageActivationPlan plan) {
|
||||
return PackageActivationResult(
|
||||
package: plan.package,
|
||||
initialDiff: plan.initialDiff,
|
||||
resources: plan.resources,
|
||||
scriptEngine: plan.scriptEngine,
|
||||
audio: plan.audio,
|
||||
);
|
||||
}
|
||||
|
||||
final GamePackage package;
|
||||
final GameDiff initialDiff;
|
||||
final GameResourceManager resources;
|
||||
final ScriptEngine scriptEngine;
|
||||
final RuntimeAudioManager? audio;
|
||||
}
|
||||
265
lib/runtime/packages/game_package_manifest.dart
Normal file
265
lib/runtime/packages/game_package_manifest.dart
Normal file
@@ -0,0 +1,265 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../display/runtime_viewport.dart';
|
||||
|
||||
class GamePackageManifest {
|
||||
const GamePackageManifest({
|
||||
required this.gameId,
|
||||
required this.name,
|
||||
required this.version,
|
||||
required this.runtimeApiVersion,
|
||||
required this.entry,
|
||||
required this.assetsBase,
|
||||
this.defaultLocale = 'en',
|
||||
this.supportedLocales = const ['en'],
|
||||
this.display = const GameDisplayConfig(),
|
||||
this.resources = const {},
|
||||
this.modules = const {},
|
||||
});
|
||||
|
||||
final String gameId;
|
||||
final String name;
|
||||
final String version;
|
||||
final int runtimeApiVersion;
|
||||
final String entry;
|
||||
final String assetsBase;
|
||||
final String defaultLocale;
|
||||
final List<String> supportedLocales;
|
||||
final GameDisplayConfig display;
|
||||
final Map<String, GameResource> resources;
|
||||
final Map<String, String> modules;
|
||||
|
||||
static GamePackageManifest fromJsonString(String source) {
|
||||
return fromMap(jsonDecode(source) as Map<String, Object?>);
|
||||
}
|
||||
|
||||
static GamePackageManifest fromMap(Map<String, Object?> map) {
|
||||
final resourcesValue = map['resources'];
|
||||
final resources = <String, GameResource>{};
|
||||
if (resourcesValue is Map) {
|
||||
for (final entry in resourcesValue.entries) {
|
||||
if (entry.key is! String || entry.value is! Map) {
|
||||
throw const FormatException('manifest.resources must be a map');
|
||||
}
|
||||
resources[entry.key as String] = GameResource.fromMap(
|
||||
Map<String, Object?>.from(entry.value as Map),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final modulesValue = map['modules'];
|
||||
final modules = <String, String>{};
|
||||
if (modulesValue is Map) {
|
||||
for (final entry in modulesValue.entries) {
|
||||
if (entry.key is! String || entry.value is! String) {
|
||||
throw const FormatException('manifest.modules must be a string map');
|
||||
}
|
||||
modules[entry.key as String] = entry.value as String;
|
||||
}
|
||||
}
|
||||
|
||||
final defaultLocale = (map['defaultLocale'] as String?) ?? 'en';
|
||||
final supportedLocales = _stringList(
|
||||
map,
|
||||
'supportedLocales',
|
||||
fallback: [defaultLocale],
|
||||
);
|
||||
if (!supportedLocales.contains(defaultLocale)) {
|
||||
throw const FormatException(
|
||||
'manifest.supportedLocales must include defaultLocale',
|
||||
);
|
||||
}
|
||||
|
||||
final displayValue = map['display'];
|
||||
final display = displayValue == null
|
||||
? const GameDisplayConfig()
|
||||
: GameDisplayConfig.fromMap(
|
||||
Map<String, Object?>.from(displayValue as Map),
|
||||
);
|
||||
|
||||
return GamePackageManifest(
|
||||
gameId: _string(map, 'gameId'),
|
||||
name: _string(map, 'name'),
|
||||
version: _string(map, 'version'),
|
||||
runtimeApiVersion: _int(map, 'runtimeApiVersion'),
|
||||
entry: _string(map, 'entry'),
|
||||
assetsBase: (map['assetsBase'] as String?) ?? 'assets',
|
||||
defaultLocale: defaultLocale,
|
||||
supportedLocales: supportedLocales,
|
||||
display: display,
|
||||
resources: resources,
|
||||
modules: modules,
|
||||
);
|
||||
}
|
||||
|
||||
static String _string(Map<String, Object?> map, String key) {
|
||||
final value = map[key];
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
throw FormatException('manifest.$key must be a non-empty string');
|
||||
}
|
||||
|
||||
static List<String> _stringList(
|
||||
Map<String, Object?> map,
|
||||
String key, {
|
||||
required List<String> fallback,
|
||||
}) {
|
||||
final value = map[key];
|
||||
if (value == null) {
|
||||
return fallback;
|
||||
}
|
||||
if (value is! List || value.isEmpty) {
|
||||
throw FormatException('manifest.$key must be a non-empty string list');
|
||||
}
|
||||
final result = <String>[];
|
||||
for (final item in value) {
|
||||
if (item is! String || item.isEmpty) {
|
||||
throw FormatException('manifest.$key must be a non-empty string list');
|
||||
}
|
||||
result.add(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static int _int(Map<String, Object?> map, String key) {
|
||||
final value = map[key];
|
||||
if (value is num) {
|
||||
return value.toInt();
|
||||
}
|
||||
throw FormatException('manifest.$key must be an integer');
|
||||
}
|
||||
}
|
||||
|
||||
class GameDisplayConfig {
|
||||
const GameDisplayConfig({
|
||||
this.designWidth = 720,
|
||||
this.designHeight = 720,
|
||||
this.scaleMode = RuntimeScaleMode.fit,
|
||||
});
|
||||
|
||||
final double designWidth;
|
||||
final double designHeight;
|
||||
final String scaleMode;
|
||||
|
||||
RuntimeViewportConfig toViewportConfig() {
|
||||
return RuntimeViewportConfig(
|
||||
designWidth: designWidth,
|
||||
designHeight: designHeight,
|
||||
scaleMode: scaleMode,
|
||||
);
|
||||
}
|
||||
|
||||
static GameDisplayConfig fromMap(Map<String, Object?> map) {
|
||||
final designWidth = _number(map, 'designWidth', fallback: 720);
|
||||
final designHeight = _number(map, 'designHeight', fallback: 720);
|
||||
final scaleMode = (map['scaleMode'] as String?) ?? RuntimeScaleMode.fit;
|
||||
if (designWidth <= 0 || designHeight <= 0) {
|
||||
throw const FormatException('manifest.display design size must be > 0');
|
||||
}
|
||||
if (!RuntimeScaleMode.isSupported(scaleMode)) {
|
||||
throw const FormatException('manifest.display.scaleMode is unsupported');
|
||||
}
|
||||
return GameDisplayConfig(
|
||||
designWidth: designWidth,
|
||||
designHeight: designHeight,
|
||||
scaleMode: scaleMode,
|
||||
);
|
||||
}
|
||||
|
||||
static double _number(
|
||||
Map<String, Object?> map,
|
||||
String key, {
|
||||
required double fallback,
|
||||
}) {
|
||||
final value = map[key];
|
||||
if (value == null) {
|
||||
return fallback;
|
||||
}
|
||||
if (value is num) {
|
||||
return value.toDouble();
|
||||
}
|
||||
throw FormatException('manifest.display.$key must be a number');
|
||||
}
|
||||
}
|
||||
|
||||
class GameResource {
|
||||
const GameResource({
|
||||
required this.type,
|
||||
required this.path,
|
||||
this.preload = GameResourcePreload.required,
|
||||
this.group,
|
||||
this.atlas,
|
||||
this.skeleton,
|
||||
});
|
||||
|
||||
final String type;
|
||||
final String path;
|
||||
final String preload;
|
||||
final String? group;
|
||||
final String? atlas;
|
||||
final String? skeleton;
|
||||
|
||||
static GameResource fromMap(Map<String, Object?> map) {
|
||||
final type = map['type'];
|
||||
final path = map['path'];
|
||||
final atlas = map['atlas'];
|
||||
final skeleton = map['skeleton'];
|
||||
if (type is! String || type.isEmpty) {
|
||||
throw const FormatException('resource.type must be a non-empty string');
|
||||
}
|
||||
if (!GameResourceType.isSupported(type)) {
|
||||
throw const FormatException('resource.type is unsupported');
|
||||
}
|
||||
if (type == GameResourceType.spine) {
|
||||
if (atlas is! String || atlas.isEmpty) {
|
||||
throw const FormatException(
|
||||
'spine resource.atlas must be a non-empty string',
|
||||
);
|
||||
}
|
||||
if (skeleton is! String || skeleton.isEmpty) {
|
||||
throw const FormatException(
|
||||
'spine resource.skeleton must be a non-empty string',
|
||||
);
|
||||
}
|
||||
} else if (path is! String || path.isEmpty) {
|
||||
throw const FormatException('resource.path must be a non-empty string');
|
||||
}
|
||||
final preload = map['preload'] as String? ?? GameResourcePreload.required;
|
||||
if (!GameResourcePreload.isSupported(preload)) {
|
||||
throw const FormatException('resource.preload is unsupported');
|
||||
}
|
||||
final group = map['group'];
|
||||
if (group != null && (group is! String || group.isEmpty)) {
|
||||
throw const FormatException('resource.group must be a non-empty string');
|
||||
}
|
||||
return GameResource(
|
||||
type: type,
|
||||
path: path as String? ?? '',
|
||||
preload: preload,
|
||||
group: group as String?,
|
||||
atlas: atlas as String?,
|
||||
skeleton: skeleton as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract final class GameResourceType {
|
||||
static const image = 'image';
|
||||
static const audio = 'audio';
|
||||
static const spine = 'spine';
|
||||
|
||||
static bool isSupported(String value) {
|
||||
return value == image || value == audio || value == spine;
|
||||
}
|
||||
}
|
||||
|
||||
abstract final class GameResourcePreload {
|
||||
static const required = 'required';
|
||||
static const lazy = 'lazy';
|
||||
static const optional = 'optional';
|
||||
|
||||
static bool isSupported(String value) {
|
||||
return value == required || value == lazy || value == optional;
|
||||
}
|
||||
}
|
||||
226
lib/runtime/packages/game_package_repository.dart
Normal file
226
lib/runtime/packages/game_package_repository.dart
Normal file
@@ -0,0 +1,226 @@
|
||||
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 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');
|
||||
}
|
||||
|
||||
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 = 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<String, Object?>,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
final String gameId;
|
||||
final String version;
|
||||
final Uri packageUrl;
|
||||
final String sha256;
|
||||
|
||||
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'),
|
||||
);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
117
lib/runtime/packages/package_verifier.dart
Normal file
117
lib/runtime/packages/package_verifier.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'game_package.dart';
|
||||
import 'game_package_manifest.dart';
|
||||
|
||||
class PackageVerifier {
|
||||
const PackageVerifier({required this.runtimeApiVersion});
|
||||
|
||||
final int runtimeApiVersion;
|
||||
|
||||
Future<void> verify(GamePackage package) async {
|
||||
_verifyManifest(package);
|
||||
await _verifyEntry(package);
|
||||
await _verifyDeclaredModules(package);
|
||||
await _verifyDeclaredResources(package);
|
||||
}
|
||||
|
||||
void _verifyManifest(GamePackage package) {
|
||||
final manifest = package.manifest;
|
||||
if (manifest.runtimeApiVersion > runtimeApiVersion) {
|
||||
throw FormatException(
|
||||
'Package runtimeApiVersion ${manifest.runtimeApiVersion} is newer than runtime $runtimeApiVersion',
|
||||
);
|
||||
}
|
||||
if (manifest.gameId.isEmpty ||
|
||||
manifest.version.isEmpty ||
|
||||
manifest.entry.isEmpty) {
|
||||
throw const FormatException('Package manifest is incomplete');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _verifyEntry(GamePackage package) async {
|
||||
final script = await package.readText(package.manifest.entry);
|
||||
if (!script.contains('function init')) {
|
||||
throw const FormatException('Lua package must define function init(ctx)');
|
||||
}
|
||||
if (!script.contains('function on_event')) {
|
||||
throw const FormatException(
|
||||
'Lua package must define function on_event(event)',
|
||||
);
|
||||
}
|
||||
if (!script.contains('function smoke_test')) {
|
||||
throw const FormatException(
|
||||
'Lua package must define function smoke_test(ctx)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _verifyDeclaredModules(GamePackage package) async {
|
||||
for (final entry in package.manifest.modules.entries) {
|
||||
final name = entry.key;
|
||||
final path = entry.value;
|
||||
if (!_isSafeModuleName(name)) {
|
||||
throw FormatException('Unsafe Lua module name: $name');
|
||||
}
|
||||
if (!_isSafeModulePath(path)) {
|
||||
throw FormatException(
|
||||
'Lua module path must be scripts/*.lua or runtime:*.lua: $path',
|
||||
);
|
||||
}
|
||||
await package.readText(path);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isSafeModuleName(String value) {
|
||||
return RegExp(r'^[A-Za-z0-9_.-]+$').hasMatch(value) &&
|
||||
!value.contains('..') &&
|
||||
!value.startsWith('.') &&
|
||||
!value.endsWith('.');
|
||||
}
|
||||
|
||||
bool _isSafeModulePath(String path) {
|
||||
if (path.startsWith(GamePackage.runtimeLuaPrefix)) {
|
||||
final name = path.substring(GamePackage.runtimeLuaPrefix.length);
|
||||
return name.isNotEmpty &&
|
||||
name.endsWith('.lua') &&
|
||||
!name.contains('/') &&
|
||||
!name.contains('..');
|
||||
}
|
||||
return path.startsWith('scripts/') &&
|
||||
path.endsWith('.lua') &&
|
||||
!path.contains('..');
|
||||
}
|
||||
|
||||
Future<void> _verifyDeclaredResources(GamePackage package) async {
|
||||
for (final resource in package.manifest.resources.values) {
|
||||
final paths = _resourcePaths(resource);
|
||||
for (final path in paths) {
|
||||
if (path.contains('..')) {
|
||||
throw const FormatException('Resource path must not contain ..');
|
||||
}
|
||||
if (package.isAsset) {
|
||||
await package.readBytes(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
final root = p.normalize(package.rootPath);
|
||||
final target = p.normalize(p.join(root, path));
|
||||
if (!p.isWithin(root, target) && target != root) {
|
||||
throw const FormatException('Resource path escapes package root');
|
||||
}
|
||||
if (!File(target).existsSync()) {
|
||||
throw FormatException('Missing declared resource: $path');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Iterable<String> _resourcePaths(GameResource resource) {
|
||||
if (resource.type == GameResourceType.spine) {
|
||||
return [resource.atlas!, resource.skeleton!];
|
||||
}
|
||||
return [resource.path];
|
||||
}
|
||||
}
|
||||
86
lib/runtime/packages/stable_package_store.dart
Normal file
86
lib/runtime/packages/stable_package_store.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../game/runtime_options.dart';
|
||||
import 'game_package.dart';
|
||||
import 'game_package_manifest.dart';
|
||||
|
||||
class StablePackageStore {
|
||||
const StablePackageStore({
|
||||
RuntimeOptions runtimeOptions = const RuntimeOptions(),
|
||||
}) : _runtimeOptions = runtimeOptions;
|
||||
|
||||
final RuntimeOptions _runtimeOptions;
|
||||
|
||||
Future<Directory> cacheRoot() async {
|
||||
final support = await getApplicationSupportDirectory();
|
||||
final root = Directory(p.join(support.path, 'flame_lua_packages'));
|
||||
root.createSync(recursive: true);
|
||||
return root;
|
||||
}
|
||||
|
||||
Future<Directory> versionDirectory(String gameId, String version) async {
|
||||
final root = await cacheRoot();
|
||||
return Directory(p.join(root.path, gameId, version));
|
||||
}
|
||||
|
||||
Future<void> markStable(GamePackage package) async {
|
||||
if (package.source != GamePackageSource.file) {
|
||||
return;
|
||||
}
|
||||
final marker = await _markerFile(package.manifest.gameId);
|
||||
marker.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));
|
||||
}
|
||||
|
||||
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']);
|
||||
}
|
||||
|
||||
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']);
|
||||
}
|
||||
|
||||
Future<File> _markerFile(String gameId) async {
|
||||
final root = await cacheRoot();
|
||||
return File(p.join(root.path, gameId, 'stable.json'));
|
||||
}
|
||||
|
||||
GamePackage? _packageFromPath(Object? pathValue) {
|
||||
if (pathValue is! String || pathValue.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final manifestFile = File(p.join(pathValue, 'manifest.json'));
|
||||
if (!manifestFile.existsSync()) {
|
||||
return null;
|
||||
}
|
||||
return GamePackage.file(
|
||||
rootPath: pathValue,
|
||||
manifest: GamePackageManifest.fromJsonString(
|
||||
manifestFile.readAsStringSync(),
|
||||
),
|
||||
runtimeLuaRoot: _runtimeOptions.runtimeLuaRoot,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user