wip
This commit is contained in:
@@ -80,6 +80,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
||||
Timer? _realmRefreshTimer;
|
||||
int? _activeGamePid;
|
||||
bool _pauseRequested = false;
|
||||
bool _gameLaunchPending = false;
|
||||
|
||||
Future<void> _onHomeScreenLoad(
|
||||
HomeScreenLoad event,
|
||||
@@ -337,17 +338,26 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
||||
HomeScreenPlayRequested event,
|
||||
Emitter<HomeScreenState> emit,
|
||||
) async {
|
||||
if (_gameLaunchPending || state.model.isGameRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
final outputPath = state.model.outputPath;
|
||||
if (outputPath == null) {
|
||||
emit(_buildErrorState('Сначала выберите папку установки.'));
|
||||
return;
|
||||
}
|
||||
|
||||
_gameLaunchPending = true;
|
||||
try {
|
||||
final installationDir = outputPath.toFilePath();
|
||||
await _gameInstallationService.clearCache(installationDir);
|
||||
final authorization = await _launcherApiClient.issueGameTicket(
|
||||
_session.accessToken,
|
||||
);
|
||||
final process = await _gameInstallationService.launchGame(
|
||||
installationDir,
|
||||
authorization: authorization,
|
||||
);
|
||||
_activeGamePid = process.pid;
|
||||
unawaited(
|
||||
@@ -382,6 +392,8 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
||||
errorMessage: _formatError(error),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
_gameLaunchPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
@@ -219,7 +220,10 @@ class GameInstallationService {
|
||||
await cacheDirectory.create(recursive: true);
|
||||
}
|
||||
|
||||
Future<GameProcessHandle> launchGame(String installationDir) async {
|
||||
Future<GameProcessHandle> launchGame(
|
||||
String installationDir, {
|
||||
required LauncherGameTicket authorization,
|
||||
}) async {
|
||||
final executablePath = getExecutablePath(installationDir);
|
||||
final executable = File(executablePath);
|
||||
|
||||
@@ -229,10 +233,29 @@ class GameInstallationService {
|
||||
);
|
||||
}
|
||||
|
||||
return startGameProcess(
|
||||
executablePath: executablePath,
|
||||
arguments: const <String>[],
|
||||
workingDirectory: installationDir,
|
||||
environment: <String, String>{
|
||||
'MOONWELL_LAUNCH_ACCOUNT': authorization.account,
|
||||
'MOONWELL_LAUNCH_TICKET': authorization.ticket,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<GameProcessHandle> startGameProcess({
|
||||
required String executablePath,
|
||||
required List<String> arguments,
|
||||
required String workingDirectory,
|
||||
required Map<String, String> environment,
|
||||
}) async {
|
||||
final process = await Process.start(
|
||||
executablePath,
|
||||
const <String>[],
|
||||
workingDirectory: installationDir,
|
||||
arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: environment,
|
||||
includeParentEnvironment: true,
|
||||
mode: ProcessStartMode.normal,
|
||||
);
|
||||
await process.stdin.close();
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:moonwell_launcher/features/launcher/domain/entities/client_manif
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_account.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_realm.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
|
||||
@@ -162,6 +163,47 @@ class LauncherApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
Future<LauncherGameTicket> issueGameTicket(String accessToken) async {
|
||||
final ticketUri = _resolveUri('api/launcher/game-ticket');
|
||||
|
||||
try {
|
||||
final response = await _dio.postUri(
|
||||
ticketUri,
|
||||
data: const <String, Object?>{'client_build': 12340},
|
||||
options: _authorizedOptions(accessToken),
|
||||
);
|
||||
final ticket = LauncherGameTicket.fromJson(_coerceMap(response.data));
|
||||
if (!ticket.isUsable()) {
|
||||
throw const LauncherApiException(
|
||||
'Launcher API returned an expired game ticket.',
|
||||
);
|
||||
}
|
||||
return ticket;
|
||||
} on DioException catch (error, stackTrace) {
|
||||
await _launcherLogService.error(
|
||||
'Game ticket request failed.',
|
||||
details: <String, Object?>{
|
||||
'endpoint': ticketUri.toString(),
|
||||
'statusCode': error.response?.statusCode,
|
||||
'dioType': error.type.name,
|
||||
},
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
throw LauncherApiException(_extractErrorMessage(error));
|
||||
} on FormatException catch (error, stackTrace) {
|
||||
await _launcherLogService.error(
|
||||
'Launcher API returned an invalid game ticket payload.',
|
||||
details: <String, Object?>{'endpoint': ticketUri.toString()},
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
throw const LauncherApiException(
|
||||
'Launcher API returned an invalid game ticket.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<ClientManifest> fetchManifest(String accessToken) async {
|
||||
final manifestUri = _resolveUri('api/launcher/manifest');
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
final class LauncherGameTicket {
|
||||
static final RegExp _accountPattern = RegExp(r'^[!-<>-~]{1,17}$');
|
||||
static final RegExp _ticketPattern = RegExp(r'^[A-Z0-9]{16}$');
|
||||
|
||||
final String account;
|
||||
final String ticket;
|
||||
final DateTime expiresAt;
|
||||
|
||||
const LauncherGameTicket({
|
||||
required this.account,
|
||||
required this.ticket,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
factory LauncherGameTicket.fromJson(Map<String, dynamic> json) {
|
||||
final rawAccount = json['account'];
|
||||
final rawTicket = json['ticket'];
|
||||
final rawExpiresAt = json['expires_at'];
|
||||
if (rawAccount is! String ||
|
||||
rawTicket is! String ||
|
||||
rawExpiresAt is! String) {
|
||||
throw const FormatException('Invalid game ticket payload types.');
|
||||
}
|
||||
|
||||
final account = rawAccount;
|
||||
final ticket = rawTicket;
|
||||
final expiresAt = DateTime.tryParse(rawExpiresAt);
|
||||
|
||||
if (!_accountPattern.hasMatch(account)) {
|
||||
throw const FormatException('Invalid game ticket account.');
|
||||
}
|
||||
if (!_ticketPattern.hasMatch(ticket)) {
|
||||
throw const FormatException('Invalid game ticket value.');
|
||||
}
|
||||
if (expiresAt == null) {
|
||||
throw const FormatException('Invalid game ticket expiration.');
|
||||
}
|
||||
|
||||
return LauncherGameTicket(
|
||||
account: account,
|
||||
ticket: ticket,
|
||||
expiresAt: expiresAt.toUtc(),
|
||||
);
|
||||
}
|
||||
|
||||
bool isUsable({
|
||||
DateTime? now,
|
||||
Duration minimumValidity = const Duration(seconds: 5),
|
||||
}) {
|
||||
final currentTime = (now ?? DateTime.now()).toUtc();
|
||||
return expiresAt.isAfter(currentTime.add(minimumValidity));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user