diff --git a/.vscode/settings.json b/.vscode/settings.json index 1395495..52efad4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "dart.flutterSdkPath": ".fvm/versions/stable" -} \ No newline at end of file + "dart.flutterSdkPath": ".fvm/versions/stable", + "cmake.sourceDirectory": "C:/Users/sindo/moonwell_launcher/windows" +} diff --git a/README.md b/README.md index bb33306..e1b20d5 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ The launcher is responsible for: - downloading and synchronizing client files - displaying launcher news - displaying the live status of the first configured realm -- launching the game client +- requesting a single-use game ticket and launching the game client without + putting credentials in command-line arguments ## Stack @@ -140,7 +141,8 @@ The launcher sync flow is manifest-based: 6. remove stale files outside ignored directories 7. download missing or changed files to temporary part files 8. verify size and SHA-256 before replacing client files -9. launch `Wow.exe` from the selected client directory +9. request a short-lived game ticket and launch `Wow.exe` from the selected + client directory with authorization in its child environment See `docs/launcher_web_api_spec.md` for the API and sync contract. diff --git a/docs/launcher_web_api_spec.md b/docs/launcher_web_api_spec.md index 964a0c3..b8b66cb 100644 --- a/docs/launcher_web_api_spec.md +++ b/docs/launcher_web_api_spec.md @@ -35,6 +35,7 @@ The launcher uses these endpoints from `openapi.json`: 5. `GET /api/launcher/news` 6. `GET /api/launcher/realms` 7. `GET /api/launcher/account` +8. `POST /api/launcher/game-ticket` Registration sequence: @@ -106,6 +107,19 @@ Account sequence: 3. The response `balance` field is intentionally ignored and never displayed. 4. Account metadata failures do not block patching or launching the game. +Game authorization sequence: + +1. Immediately before starting the game, the launcher calls + `POST /api/launcher/game-ticket` with bearer authentication and + `{ "client_build": 12340 }` in the JSON body. +2. The API returns `account`, a 16-character `A-Z0-9` single-use `ticket`, and + `expires_at`. The ticket must remain valid for at least five more seconds. +3. Issuing a ticket atomically revokes any previous unused game ticket for the + same account. Production lifetime must not exceed 60 seconds. +4. The launcher never persists or logs the ticket. +5. The auth server uses the ticket as the temporary SRP password and consumes + it atomically after a successful logon proof. + ## Installation Directory Rules The user selects a single installation root directory. @@ -232,11 +246,14 @@ When the user presses `Play`: 1. launcher resolves `/Wow.exe` 2. launcher clears `/Cache` -3. launcher starts `Wow.exe` with working directory set to installation root - and retains a process handle -4. while the process is alive, the launcher disables repeated game launches +3. launcher requests a single-use game ticket +4. launcher starts `Wow.exe` with working directory set to installation root, + no authorization command-line arguments, and these child environment values: + - `MOONWELL_LAUNCH_ACCOUNT=` + - `MOONWELL_LAUNCH_TICKET=` +5. launcher retains a process handle; while the process is alive, it disables repeated game launches and client synchronization -5. when the process exits, the launcher returns to the ready-to-play state +6. when the process exits, the launcher returns to the ready-to-play state If `Wow.exe` is missing, launch fails with an error. @@ -253,6 +270,7 @@ The launcher surfaces errors for: - missing downloaded temp files before verification - final verification mismatch after update - missing `Wow.exe` +- invalid, expired, or unavailable game ticket ## Tested Invariants diff --git a/installer/moonwell_launcher.iss b/installer/moonwell_launcher.iss index ebf3443..df70875 100644 --- a/installer/moonwell_launcher.iss +++ b/installer/moonwell_launcher.iss @@ -44,5 +44,11 @@ Source: "{#MyBuildDir}\*"; DestDir: "{app}"; Excludes: "moonwell_launcher.exe"; Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon +[Registry] +Root: HKCU; Subkey: "Software\Classes\moonwell"; ValueType: string; ValueName: ""; ValueData: "URL:MoonWell Launcher Protocol"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\moonwell"; ValueType: string; ValueName: "URL Protocol"; ValueData: "" +Root: HKCU; Subkey: "Software\Classes\moonwell\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0" +Root: HKCU; Subkey: "Software\Classes\moonwell\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1""" + [Run] Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent diff --git a/lib/app/home_screen/bloc/home_screen_bloc.dart b/lib/app/home_screen/bloc/home_screen_bloc.dart index 4789a71..3fb4182 100644 --- a/lib/app/home_screen/bloc/home_screen_bloc.dart +++ b/lib/app/home_screen/bloc/home_screen_bloc.dart @@ -80,6 +80,7 @@ class HomeScreenBloc extends Bloc { Timer? _realmRefreshTimer; int? _activeGamePid; bool _pauseRequested = false; + bool _gameLaunchPending = false; Future _onHomeScreenLoad( HomeScreenLoad event, @@ -337,17 +338,26 @@ class HomeScreenBloc extends Bloc { HomeScreenPlayRequested event, Emitter 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 { errorMessage: _formatError(error), ), ); + } finally { + _gameLaunchPending = false; } } diff --git a/lib/features/launcher/data/game_installation_service.dart b/lib/features/launcher/data/game_installation_service.dart index e4ce881..35acb8f 100644 --- a/lib/features/launcher/data/game_installation_service.dart +++ b/lib/features/launcher/data/game_installation_service.dart @@ -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 launchGame(String installationDir) async { + Future 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 [], + workingDirectory: installationDir, + environment: { + 'MOONWELL_LAUNCH_ACCOUNT': authorization.account, + 'MOONWELL_LAUNCH_TICKET': authorization.ticket, + }, + ); + } + + Future startGameProcess({ + required String executablePath, + required List arguments, + required String workingDirectory, + required Map environment, + }) async { final process = await Process.start( executablePath, - const [], - workingDirectory: installationDir, + arguments, + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: true, mode: ProcessStartMode.normal, ); await process.stdin.close(); diff --git a/lib/features/launcher/data/launcher_api_client.dart b/lib/features/launcher/data/launcher_api_client.dart index 527fafa..7e22269 100644 --- a/lib/features/launcher/data/launcher_api_client.dart +++ b/lib/features/launcher/data/launcher_api_client.dart @@ -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 issueGameTicket(String accessToken) async { + final ticketUri = _resolveUri('api/launcher/game-ticket'); + + try { + final response = await _dio.postUri( + ticketUri, + data: const {'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: { + '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: {'endpoint': ticketUri.toString()}, + error: error, + stackTrace: stackTrace, + ); + throw const LauncherApiException( + 'Launcher API returned an invalid game ticket.', + ); + } + } + Future fetchManifest(String accessToken) async { final manifestUri = _resolveUri('api/launcher/manifest'); diff --git a/lib/features/launcher/domain/entities/launcher_game_ticket.dart b/lib/features/launcher/domain/entities/launcher_game_ticket.dart new file mode 100644 index 0000000..d1b55af --- /dev/null +++ b/lib/features/launcher/domain/entities/launcher_game_ticket.dart @@ -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 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)); + } +} diff --git a/openapi.json b/openapi.json index 4187359..d2d0cad 100644 --- a/openapi.json +++ b/openapi.json @@ -109,6 +109,83 @@ } } }, + "/api/launcher/game-ticket": { + "post": { + "tags": [ + "Launcher Auth" + ], + "summary": "Выпустить одноразовый ticket для входа в игру", + "description": "Отзывает предыдущий неиспользованный ticket аккаунта и возвращает временный SRP-пароль. Ticket не сохраняется и действует не более 60 секунд.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "required": [ + "client_build" + ], + "properties": { + "client_build": { + "type": "integer", + "example": 12340 + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Одноразовая игровая авторизация", + "content": { + "application/json": { + "schema": { + "required": [ + "account", + "ticket", + "expires_at" + ], + "properties": { + "account": { + "type": "string", + "example": "PLAYERONE", + "maxLength": 17, + "minLength": 1 + }, + "ticket": { + "type": "string", + "pattern": "^[A-Z0-9]{16}$", + "example": "A1B2C3D4E5F6G7H8" + }, + "expires_at": { + "type": "string", + "format": "date-time", + "example": "2026-08-16T12:01:00Z" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Launcher-сессия отсутствует или истекла" + }, + "422": { + "description": "Версия клиента не разрешена" + }, + "429": { + "description": "Слишком много запросов" + } + }, + "security": [ + { + "launcherAuth": [] + } + ] + } + }, "/api/launcher/manifest": { "get": { "tags": [ @@ -702,4 +779,4 @@ "description": "Launcher Service" } ] -} \ No newline at end of file +} diff --git a/test/app/home_screen/bloc/home_screen_bloc_test.dart b/test/app/home_screen/bloc/home_screen_bloc_test.dart index cd23d11..684f6f2 100644 --- a/test/app/home_screen/bloc/home_screen_bloc_test.dart +++ b/test/app/home_screen/bloc/home_screen_bloc_test.dart @@ -11,6 +11,7 @@ import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dar import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_account.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'; @@ -168,6 +169,8 @@ void main() { await pumpEventQueue(times: 20); expect(gameService.launchCount, 1); + expect(gameService.launchedWith?.account, 'PLAYERONE'); + expect(gameService.launchedWith?.ticket, 'A1B2C3D4E5F6G7H8'); expect(bloc.state.model.isGameRunning, isTrue); expect(bloc.state.model.canPlay, isFalse); @@ -234,6 +237,7 @@ class _FakeLauncherApiClient extends LauncherApiClient { final List realms; final LauncherAccount account; int manifestRequestCount = 0; + int gameTicketRequestCount = 0; @override Future> fetchNews(String accessToken) async => @@ -245,6 +249,16 @@ class _FakeLauncherApiClient extends LauncherApiClient { @override Future fetchAccount(String accessToken) async => account; + @override + Future issueGameTicket(String accessToken) async { + gameTicketRequestCount += 1; + return LauncherGameTicket( + account: 'PLAYERONE', + ticket: 'A1B2C3D4E5F6G7H8', + expiresAt: DateTime.now().toUtc().add(const Duration(seconds: 60)), + ); + } + @override Future fetchManifest(String accessToken) async { manifestRequestCount += 1; @@ -272,6 +286,7 @@ class _FakeGameInstallationService extends GameInstallationService { class _TrackingGameInstallationService extends _FakeGameInstallationService { final Completer exitCode = Completer(); int launchCount = 0; + LauncherGameTicket? launchedWith; @override Future hasClientExecutable(String installationDir) async => true; @@ -280,8 +295,12 @@ class _TrackingGameInstallationService extends _FakeGameInstallationService { Future clearCache(String installationDir) async {} @override - Future launchGame(String installationDir) async { + Future launchGame( + String installationDir, { + required LauncherGameTicket authorization, + }) async { launchCount += 1; + launchedWith = authorization; return GameProcessHandle(pid: 42, exitCode: exitCode.future); } } diff --git a/test/features/launcher/data/game_installation_service_test.dart b/test/features/launcher/data/game_installation_service_test.dart index 1b7273e..4e1be04 100644 --- a/test/features/launcher/data/game_installation_service_test.dart +++ b/test/features/launcher/data/game_installation_service_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart'; // import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart'; +import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart'; void main() { group('GameInstallationService', () { @@ -125,5 +126,45 @@ void main() { throwsA(isA()), ); }); + + test( + 'launchGame passes authorization only through child environment', + () async { + final recordingService = _RecordingGameInstallationService(); + final authorization = LauncherGameTicket( + account: 'PLAYERONE', + ticket: 'A1B2C3D4E5F6G7H8', + expiresAt: DateTime.now().toUtc().add(const Duration(seconds: 60)), + ); + + await recordingService.launchGame( + rootDirectory.path, + authorization: authorization, + ); + + expect(recordingService.arguments, isEmpty); + expect(recordingService.environment, { + 'MOONWELL_LAUNCH_ACCOUNT': 'PLAYERONE', + 'MOONWELL_LAUNCH_TICKET': 'A1B2C3D4E5F6G7H8', + }); + }, + ); }); } + +final class _RecordingGameInstallationService extends GameInstallationService { + Map? environment; + List? arguments; + + @override + Future startGameProcess({ + required String executablePath, + required List arguments, + required String workingDirectory, + required Map environment, + }) async { + this.arguments = List.from(arguments); + this.environment = Map.from(environment); + return GameProcessHandle(pid: 42, exitCode: Future.value(0)); + } +} diff --git a/test/features/launcher/domain/entities/launcher_game_ticket_test.dart b/test/features/launcher/domain/entities/launcher_game_ticket_test.dart new file mode 100644 index 0000000..591e406 --- /dev/null +++ b/test/features/launcher/domain/entities/launcher_game_ticket_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart'; + +void main() { + group('LauncherGameTicket', () { + test('parses the launcher authorization contract', () { + final ticket = LauncherGameTicket.fromJson({ + 'account': 'PLAYERONE', + 'ticket': 'A1B2C3D4E5F6G7H8', + 'expires_at': '2026-08-16T12:01:00Z', + }); + + expect(ticket.account, 'PLAYERONE'); + expect(ticket.ticket, 'A1B2C3D4E5F6G7H8'); + expect(ticket.expiresAt, DateTime.utc(2026, 8, 16, 12, 1)); + }); + + test('rejects tickets outside the native client format', () { + expect( + () => LauncherGameTicket.fromJson({ + 'account': 'PLAYERONE', + 'ticket': 'lowercase-ticket', + 'expires_at': '2026-08-16T12:01:00Z', + }), + throwsFormatException, + ); + }); + + test('does not normalize credentials from the API', () { + expect( + () => LauncherGameTicket.fromJson({ + 'account': 'PLAYER ', + 'ticket': ' A1B2C3D4E5F6G7H8', + 'expires_at': '2026-08-16T12:01:00Z', + }), + throwsFormatException, + ); + }); + + test('requires enough validity to start the game', () { + final ticket = LauncherGameTicket( + account: 'PLAYERONE', + ticket: 'A1B2C3D4E5F6G7H8', + expiresAt: DateTime.utc(2026, 8, 16, 12, 0, 4), + ); + + expect(ticket.isUsable(now: DateTime.utc(2026, 8, 16, 12)), isFalse); + }); + }); + + test('rejects non-string payload fields with a format exception', () { + expect( + () => LauncherGameTicket.fromJson({ + 'account': 42, + 'ticket': 'ABCDEFGH12345678', + 'expires_at': '2026-08-16T20:00:00Z', + }), + throwsFormatException, + ); + }); +}