diff --git a/docs/launcher_web_api_spec.md b/docs/launcher_web_api_spec.md index b8b66cb..91c786c 100644 --- a/docs/launcher_web_api_spec.md +++ b/docs/launcher_web_api_spec.md @@ -244,16 +244,18 @@ Pause is implemented as cooperative cancellation: When the user presses `Play`: -1. launcher resolves `/Wow.exe` -2. launcher clears `/Cache` -3. launcher requests a single-use game ticket -4. launcher starts `Wow.exe` with working directory set to installation root, +1. launcher removes every `SET accountName ...` line from + `/WTF/Config.wtf` when that file exists +2. launcher resolves `/Wow.exe` +3. launcher clears `/Cache` +4. launcher requests a single-use game ticket +5. 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 +6. launcher retains a process handle; while the process is alive, it disables repeated game launches and client synchronization -6. when the process exits, the launcher returns to the ready-to-play state +7. when the process exits, the launcher returns to the ready-to-play state If `Wow.exe` is missing, launch fails with an error. diff --git a/lib/app/home_screen/bloc/home_screen_bloc.dart b/lib/app/home_screen/bloc/home_screen_bloc.dart index 3fb4182..f71c8f7 100644 --- a/lib/app/home_screen/bloc/home_screen_bloc.dart +++ b/lib/app/home_screen/bloc/home_screen_bloc.dart @@ -351,6 +351,7 @@ class HomeScreenBloc extends Bloc { _gameLaunchPending = true; try { final installationDir = outputPath.toFilePath(); + await _gameInstallationService.removeSavedAccountName(installationDir); await _gameInstallationService.clearCache(installationDir); final authorization = await _launcherApiClient.issueGameTicket( _session.accessToken, diff --git a/lib/features/launcher/data/game_installation_service.dart b/lib/features/launcher/data/game_installation_service.dart index 35acb8f..1e44094 100644 --- a/lib/features/launcher/data/game_installation_service.dart +++ b/lib/features/launcher/data/game_installation_service.dart @@ -220,6 +220,27 @@ class GameInstallationService { await cacheDirectory.create(recursive: true); } + Future removeSavedAccountName(String installationDir) async { + final configFile = File(p.join(installationDir, 'WTF', 'Config.wtf')); + if (!await configFile.exists()) { + return; + } + + final contents = await configFile.readAsString(); + final sanitizedContents = contents.replaceAll( + RegExp( + r'^[ \t]*SET[ \t]+accountName(?:[ \t]+.*)?(?:\r\n|\n|\r|$)', + caseSensitive: false, + multiLine: true, + ), + '', + ); + + if (sanitizedContents != contents) { + await configFile.writeAsString(sanitizedContents, flush: true); + } + } + Future launchGame( String installationDir, { required LauncherGameTicket authorization, 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 684f6f2..b1faace 100644 --- a/test/app/home_screen/bloc/home_screen_bloc_test.dart +++ b/test/app/home_screen/bloc/home_screen_bloc_test.dart @@ -148,11 +148,15 @@ void main() { final manifest = ClientManifest.fromJson({ 'files': >[], }); - final gameService = _TrackingGameInstallationService(); + final launchOperations = []; + final gameService = _TrackingGameInstallationService(launchOperations); final bloc = HomeScreenBloc( clientSyncUseCase: _CapturingClientSyncUseCase(), gameInstallationService: gameService, - launcherApiClient: _FakeLauncherApiClient(newsItems: const []), + launcherApiClient: _FakeLauncherApiClient( + newsItems: const [], + launchOperations: launchOperations, + ), preferencesRepository: _FakePreferencesRepository( outputDir: Uri.directory('C:/World of Warcraft'), ), @@ -169,6 +173,13 @@ void main() { await pumpEventQueue(times: 20); expect(gameService.launchCount, 1); + expect(gameService.savedAccountNameRemovalCount, 1); + expect(launchOperations, [ + 'removeSavedAccountName', + 'clearCache', + 'issueGameTicket', + 'launchGame', + ]); expect(gameService.launchedWith?.account, 'PLAYERONE'); expect(gameService.launchedWith?.ticket, 'A1B2C3D4E5F6G7H8'); expect(bloc.state.model.isGameRunning, isTrue); @@ -230,12 +241,14 @@ class _FakeLauncherApiClient extends LauncherApiClient { this.manifest, this.realms = const [], this.account = const LauncherAccount(username: ''), + this.launchOperations, }); final List newsItems; final ClientManifest? manifest; final List realms; final LauncherAccount account; + final List? launchOperations; int manifestRequestCount = 0; int gameTicketRequestCount = 0; @@ -251,6 +264,7 @@ class _FakeLauncherApiClient extends LauncherApiClient { @override Future issueGameTicket(String accessToken) async { + launchOperations?.add('issueGameTicket'); gameTicketRequestCount += 1; return LauncherGameTicket( account: 'PLAYERONE', @@ -284,21 +298,34 @@ class _FakeGameInstallationService extends GameInstallationService { } class _TrackingGameInstallationService extends _FakeGameInstallationService { + _TrackingGameInstallationService(this.launchOperations); + + final List launchOperations; final Completer exitCode = Completer(); int launchCount = 0; + int savedAccountNameRemovalCount = 0; LauncherGameTicket? launchedWith; @override Future hasClientExecutable(String installationDir) async => true; @override - Future clearCache(String installationDir) async {} + Future removeSavedAccountName(String installationDir) async { + launchOperations.add('removeSavedAccountName'); + savedAccountNameRemovalCount += 1; + } + + @override + Future clearCache(String installationDir) async { + launchOperations.add('clearCache'); + } @override Future launchGame( String installationDir, { required LauncherGameTicket authorization, }) async { + launchOperations.add('launchGame'); 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 4e1be04..2bf2ce0 100644 --- a/test/features/launcher/data/game_installation_service_test.dart +++ b/test/features/launcher/data/game_installation_service_test.dart @@ -92,6 +92,30 @@ void main() { expect(await cacheDirectory.list().isEmpty, isTrue); }); + test('removeSavedAccountName removes accountName lines only', () async { + final configFile = File( + '${rootDirectory.path}${Platform.pathSeparator}WTF${Platform.pathSeparator}Config.wtf', + ); + await configFile.parent.create(recursive: true); + await configFile.writeAsString( + 'SET gxWindow "1"\r\n' + 'SET accountName "admin#&|𞉀#&|�"\r\n' + 'set ACCOUNTNAME "another account"\r\n' + 'SET locale "ruRU"\r\n', + ); + + await service.removeSavedAccountName(rootDirectory.path); + + expect( + await configFile.readAsString(), + 'SET gxWindow "1"\r\nSET locale "ruRU"\r\n', + ); + }); + + test('removeSavedAccountName ignores a missing Config.wtf', () async { + await service.removeSavedAccountName(rootDirectory.path); + }); + test('ensureParentDirectoryExists creates nested directories', () async { final tempPath = '${rootDirectory.path}${Platform.pathSeparator}Data${Platform.pathSeparator}patches${Platform.pathSeparator}common-2.MPQ.moonwell.part';