From 2ffb7908adfd7e030ce7e7c63c0d41cdf0fbbdc1 Mon Sep 17 00:00:00 2001 From: koren <131921956+kkorenn@users.noreply.github.com> Date: Sat, 27 Jun 2026 09:53:13 +0900 Subject: [PATCH] feat: auto-set Steam Launch Options after MelonLoader install Until now the app only showed a guide telling users to paste "setup_helper.sh" %command% into Steam's per-game Launch Options by hand. Port the targeted localconfig.vdf editor from the reference Swift installer (sbrothers7/UMMInstall) to Dart so the wrapper is wired up automatically on install and cleared on uninstall. - steam_config.dart: cross-platform (macOS/Linux/Windows) localconfig.vdf reader/editor. Does a scoped, backup-first, atomic edit of the apps -> -> LaunchOptions value rather than re-serializing, so the rest of the file is preserved. Brace/quote-aware scan handles nested blocks and escaped quotes; clear() only removes our own wrapper so a user's custom launch options are left untouched. - Game.steamLaunchOptionsValue()/isProtonOrWineInstall(): compute the correct value per game + platform (macOS absolute path, Linux native relative path, Proton/Wine WINEDLLOVERRIDES). Windows = none. - InstallerState: apply after a successful install, clear on uninstall, surface an auto-set confirmation (with a "restart Steam" hint when Steam is running) above the existing manual guide, which stays as a fallback. - Localization for ko-KR / en-US / zh-CN. - Unit tests for the VDF editor (set/insert/scope/case/escaping/clear). Co-Authored-By: Claude Opus 4.8 --- lib/src/core/dancing_line_game.dart | 6 + lib/src/core/game.dart | 32 ++ lib/src/core/installer_state.dart | 68 +++++ lib/src/core/localization.dart | 9 + lib/src/core/rhythm_doctor_game.dart | 6 + lib/src/core/steam_config.dart | 433 +++++++++++++++++++++++++++ lib/src/ui/installed_tab.dart | 11 + test/steam_config_test.dart | 200 +++++++++++++ 8 files changed, 765 insertions(+) create mode 100644 lib/src/core/steam_config.dart create mode 100644 test/steam_config_test.dart diff --git a/lib/src/core/dancing_line_game.dart b/lib/src/core/dancing_line_game.dart index 511495f..a61be27 100644 --- a/lib/src/core/dancing_line_game.dart +++ b/lib/src/core/dancing_line_game.dart @@ -20,6 +20,12 @@ class DancingLineGame extends Game { @override String? get steamAppId => '4395300'; + @override + List windowsExecutableNames() => const [ + 'Dancing Line.exe', + 'DancingLine.exe', + ]; + @override List getSteamInstallFolderNames() => const [ 'Dancing Line', diff --git a/lib/src/core/game.dart b/lib/src/core/game.dart index 62642ec..44f6d20 100644 --- a/lib/src/core/game.dart +++ b/lib/src/core/game.dart @@ -76,6 +76,38 @@ abstract class Game { // Folder names Steam may use under steamapps/common. List getSteamInstallFolderNames() => [name]; + /// Candidate Windows executable file names, used to detect a Proton/Wine + /// install on a non-Windows host. Subclasses override when the Windows build + /// uses a name that differs from `.exe`. + List windowsExecutableNames() => ['$name.exe']; + + /// True when this install is the Windows build running through Proton/Wine — + /// i.e. a Windows .exe is present on a non-Windows host. + bool isProtonOrWineInstall(String gamePath) { + if (Platform.isWindows) return false; + for (final exe in windowsExecutableNames()) { + if (File(p.join(gamePath, exe)).existsSync()) return true; + } + return false; + } + + /// The exact Steam Launch Options string MelonLoader needs for this install, + /// or null when none is required (Windows, where the bootstrap injects + /// without a wrapper). Used to auto-write Steam's localconfig.vdf after + /// install and to populate the manual guide. + String? steamLaunchOptionsValue(String gamePath) { + if (Platform.isWindows) return null; + if (isProtonOrWineInstall(gamePath)) { + return r'WINEDLLOVERRIDES="winhttp=n,b" %command%'; + } + if (Platform.isMacOS) { + // Steam on macOS does not resolve relative paths; use the absolute script. + return '"${p.join(gamePath, 'setup_helper.sh')}" %command%'; + } + // Linux native + return './setup_helper.sh %command%'; + } + String? findSteamInstallPath() { for (final candidate in getSteamInstallCandidatePaths()) { if (isValidGamePath(candidate)) { diff --git a/lib/src/core/installer_state.dart b/lib/src/core/installer_state.dart index e74b7a7..54aedea 100644 --- a/lib/src/core/installer_state.dart +++ b/lib/src/core/installer_state.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:path/path.dart' as p; import 'game.dart'; +import 'steam_config.dart'; import 'adofai_game.dart'; import 'dancing_line_game.dart'; import 'rhythm_doctor_game.dart'; @@ -44,6 +45,11 @@ class InstallerState extends ChangeNotifier { bool _isProcessing = false; String? _statusMessage; + // Whether the last install auto-wrote Steam's Launch Options for this game, + // and whether Steam needs a full restart for that write to take effect. + bool _steamLaunchOptionsApplied = false; + bool _steamLaunchOptionsNeedRestart = false; + // Mod Update Cache & Status final Map _onlineModsCache = {}; List _modsWithUpdates = []; @@ -73,6 +79,9 @@ class InstallerState extends ChangeNotifier { bool get isUmmDetected => _isUmmDetected; String get loaderVersion => _loaderVersion; bool get isLoaderOutdated => _isLoaderOutdated; + + bool get steamLaunchOptionsApplied => _steamLaunchOptionsApplied; + bool get steamLaunchOptionsNeedRestart => _steamLaunchOptionsNeedRestart; String get locale => _locale; @@ -325,6 +334,8 @@ class InstallerState extends ChangeNotifier { _isProcessing = true; _progress = 0.0; _statusMessage = t('status_loader_downloading'); + _steamLaunchOptionsApplied = false; + _steamLaunchOptionsNeedRestart = false; notifyListeners(); var installTimedOut = false; @@ -371,6 +382,10 @@ class InstallerState extends ChangeNotifier { ); await DebugLog.info('Core MelonLoader install completed'); + // Point Steam's Launch Options at the setup_helper.sh wrapper so the + // loader actually injects on launch (no-op on Windows / unknown appid). + await _applySteamLaunchOptions(); + if (_isUmmDetected && installUmmCompat) { _statusMessage = t('status_loader_checking_ummcompat'); notifyListeners(); @@ -412,6 +427,58 @@ class InstallerState extends ChangeNotifier { } } + // Steam Launch Options 자동 설정 (localconfig.vdf 직접 편집) + Future _applySteamLaunchOptions() async { + final appId = game.steamAppId; + final value = game.steamLaunchOptionsValue(_gamePath); + if (appId == null || value == null) { + // Windows (no wrapper needed) or unknown app id — nothing to write. + return; + } + try { + final result = await SteamConfig.setLaunchOptions( + appId: appId, + value: value, + ); + _steamLaunchOptionsApplied = result.applied; + _steamLaunchOptionsNeedRestart = result.applied && result.steamRunning; + await DebugLog.info( + 'Steam launch options: applied=${result.applied} ' + 'updated=${result.updatedCount} configFound=${result.configFound} ' + 'steamRunning=${result.steamRunning} appId=$appId', + ); + } catch (e, stackTrace) { + _steamLaunchOptionsApplied = false; + await DebugLog.error( + 'Failed to set Steam launch options', + error: e, + stackTrace: stackTrace, + ); + } + } + + // Steam Launch Options 정리 (우리가 설정한 래퍼만 제거) + Future _clearSteamLaunchOptions() async { + final appId = game.steamAppId; + if (appId == null) return; + try { + final result = await SteamConfig.clearLaunchOptions(appId: appId); + await DebugLog.info( + 'Cleared Steam launch options: updated=${result.updatedCount} ' + 'appId=$appId', + ); + } catch (e, stackTrace) { + await DebugLog.error( + 'Failed to clear Steam launch options', + error: e, + stackTrace: stackTrace, + ); + } finally { + _steamLaunchOptionsApplied = false; + _steamLaunchOptionsNeedRestart = false; + } + } + // UMM 호환 모드(ummcompat) 설치 Future installUmmCompat() async { if (_isProcessing || !_isValidPath) return; @@ -500,6 +567,7 @@ class InstallerState extends ChangeNotifier { try { await game.uninstallLoader(_gamePath); + await _clearSteamLaunchOptions(); _statusMessage = t('status_loader_uninstall_success'); } catch (e) { _statusMessage = t('status_loader_uninstall_failed', args: {'error': describeAppError(e)}); diff --git a/lib/src/core/localization.dart b/lib/src/core/localization.dart index 81f19a0..90d2f2f 100644 --- a/lib/src/core/localization.dart +++ b/lib/src/core/localization.dart @@ -92,6 +92,9 @@ class Localization { 'installed_copied_clipboard': '클립보드에 복사되었습니다.', 'installed_err_file_picker': '파일 선택 중 오류가 발생했습니다: {error}', 'installed_launch_guide_title': '💡 비윈도우 OS 추가 안내 사항', + 'installed_launch_auto_set': '✓ 스팀 실행 옵션이 자동으로 설정되었습니다.', + 'installed_launch_auto_set_restart': + '변경 사항을 적용하려면 스팀을 완전히 종료한 후 다시 실행하세요.', 'installed_btn_copy_native_launch': '네이티브 시작 옵션 복사', 'installed_btn_copy_proton_launch': 'Proton 시작 옵션 복사', 'settings_btn_check_updates': '업데이트 확인', @@ -375,6 +378,10 @@ class Localization { 'installed_copied_clipboard': 'Copied to clipboard.', 'installed_err_file_picker': 'An error occurred while picking file: {error}', 'installed_launch_guide_title': '💡 Additional Guide for Non-Windows OS', + 'installed_launch_auto_set': + '✓ Steam launch options were configured automatically.', + 'installed_launch_auto_set_restart': + 'Fully quit and reopen Steam for the change to take effect.', 'installed_btn_copy_native_launch': 'Copy Native Launch Options', 'installed_btn_copy_proton_launch': 'Copy Proton Launch Options', 'settings_btn_check_updates': 'Check for Updates', @@ -656,6 +663,8 @@ class Localization { 'installed_copied_clipboard': '已复制到剪贴板。', 'installed_err_file_picker': '选择文件时出错: {error}', 'installed_launch_guide_title': '💡 非 Windows 系统额外说明', + 'installed_launch_auto_set': '✓ 已自动配置 Steam 启动选项。', + 'installed_launch_auto_set_restart': '请完全退出并重新打开 Steam 以使更改生效。', 'installed_btn_copy_native_launch': '复制原生启动选项', 'installed_btn_copy_proton_launch': '复制 Proton 启动选项', 'settings_btn_check_updates': '检查更新', diff --git a/lib/src/core/rhythm_doctor_game.dart b/lib/src/core/rhythm_doctor_game.dart index cd9e650..d7b5226 100644 --- a/lib/src/core/rhythm_doctor_game.dart +++ b/lib/src/core/rhythm_doctor_game.dart @@ -20,6 +20,12 @@ class RhythmDoctorGame extends Game { @override String? get steamAppId => '774181'; + @override + List windowsExecutableNames() => const [ + 'Rhythm Doctor.exe', + 'RhythmDoctor.exe', + ]; + @override List getSteamInstallFolderNames() => const [ 'Rhythm Doctor', diff --git a/lib/src/core/steam_config.dart b/lib/src/core/steam_config.dart new file mode 100644 index 0000000..ce8ed45 --- /dev/null +++ b/lib/src/core/steam_config.dart @@ -0,0 +1,433 @@ +import 'dart:io'; +import 'package:path/path.dart' as p; + +/// Result of a Steam Launch Options write/clear. +class SteamLaunchOptionsResult { + /// Number of per-account `localconfig.vdf` files that were updated (or + /// already held the desired value). + final int updatedCount; + + /// Whether Steam appeared to be running when the edit happened. Steam + /// rewrites `localconfig.vdf` on exit, so edits made while it is running do + /// not stick until it is fully restarted. + final bool steamRunning; + + /// Whether any `localconfig.vdf` file was found at all. + final bool configFound; + + const SteamLaunchOptionsResult({ + required this.updatedCount, + required this.steamRunning, + required this.configFound, + }); + + bool get applied => updatedCount > 0; + + static const SteamLaunchOptionsResult none = SteamLaunchOptionsResult( + updatedCount: 0, + steamRunning: false, + configFound: false, + ); +} + +/// Reads and edits Steam's per-account `localconfig.vdf` to set (or clear) the +/// Launch Options for a given app, so the MelonLoader `setup_helper.sh` wrapper +/// runs automatically. We do a targeted edit (backing the file up first) rather +/// than a full re-serialize, so the rest of the file is preserved byte-for-byte. +/// +/// Ported from the reference Swift implementation in sbrothers7/UMMInstall. +class SteamConfig { + // ASCII code units used while scanning the (mostly-ASCII) VDF structure. + static const int _quote = 0x22; // " + static const int _backslash = 0x5C; // \ + static const int _openBrace = 0x7B; // { + static const int _closeBrace = 0x7D; // } + static const int _space = 0x20; + static const int _tab = 0x09; + static const int _nl = 0x0A; + static const int _cr = 0x0D; + + static int _lower(int c) => (c >= 0x41 && c <= 0x5A) ? c + 0x20 : c; + + // MARK: - Public API + + /// Sets the Launch Options for [appId] to [value] across every Steam account + /// on this machine. [value] is the raw Steam Launch Options string (e.g. + /// `"/path/setup_helper.sh" %command%`); embedded quotes/backslashes are + /// escaped before being written into the VDF. + static Future setLaunchOptions({ + required String appId, + required String value, + }) async { + final paths = _localConfigPaths(); + final running = await isSteamRunning(); + if (paths.isEmpty) { + return SteamLaunchOptionsResult( + updatedCount: 0, + steamRunning: running, + configFound: false, + ); + } + + final escaped = value + .replaceAll('\\', r'\\') + .replaceAll('"', r'\"'); + + var updated = 0; + for (final path in paths) { + String text; + try { + text = await File(path).readAsString(); + } catch (_) { + continue; + } + final newText = setLaunchOptionsInVdf( + text, + appId: appId, + escapedValue: escaped, + ); + if (newText == null) continue; + if (newText == text) { + updated++; + continue; + } + if (await _backupAndWrite(path, newText)) { + updated++; + } + } + + return SteamLaunchOptionsResult( + updatedCount: updated, + steamRunning: running, + configFound: true, + ); + } + + /// Clears the Launch Options we set for [appId] — but only when the current + /// value still references one of our wrappers, so a user's own custom option + /// is left untouched. + static Future clearLaunchOptions({ + required String appId, + }) async { + final paths = _localConfigPaths(); + final running = await isSteamRunning(); + if (paths.isEmpty) { + return SteamLaunchOptionsResult( + updatedCount: 0, + steamRunning: running, + configFound: false, + ); + } + + var updated = 0; + for (final path in paths) { + String text; + try { + text = await File(path).readAsString(); + } catch (_) { + continue; + } + final newText = clearLaunchOptionsInVdf(text, appId: appId); + if (newText == null || newText == text) continue; + if (await _backupAndWrite(path, newText)) { + updated++; + } + } + + return SteamLaunchOptionsResult( + updatedCount: updated, + steamRunning: running, + configFound: true, + ); + } + + /// Best-effort check of whether the Steam client is currently running. + static Future isSteamRunning() async { + try { + if (Platform.isWindows) { + final r = await Process.run('tasklist', [ + '/FI', + 'IMAGENAME eq steam.exe', + '/NH', + ]); + return r.stdout.toString().toLowerCase().contains('steam.exe'); + } + // macOS process is `steam_osx`; Linux is `steam`. `pgrep -i steam` + // matches both (and the helper processes), which is all we need here. + final r = await Process.run('pgrep', ['-i', 'steam']); + return r.exitCode == 0 && r.stdout.toString().trim().isNotEmpty; + } catch (_) { + return false; + } + } + + // MARK: - Pure VDF editing (filesystem-free, unit-testable) + + /// Returns [text] with app [appId]'s LaunchOptions set to [escapedValue] + /// (already VDF-escaped, without surrounding quotes), or null if the file's + /// `apps` structure couldn't be located. + static String? setLaunchOptionsInVdf( + String text, { + required String appId, + required String escapedValue, + }) { + final chars = List.of(text.codeUnits); + + // The relevant app block lives inside Steam's "apps" block; scope the + // search there so an unrelated id elsewhere in the file can't match. + final apps = _blockRange(chars, 'apps', 0, chars.length); + if (apps == null) return null; + + final block = _blockRange(chars, appId, apps.start, apps.end); + if (block != null) { + final valueRange = _launchOptionsValueRange(chars, block.start, block.end); + if (valueRange != null) { + chars.replaceRange( + valueRange.start, + valueRange.end, + '"$escapedValue"'.codeUnits, + ); + return String.fromCharCodes(chars); + } + // App block exists but has no LaunchOptions yet — insert one. + final insertion = '\n\t\t\t\t\t"LaunchOptions"\t\t"$escapedValue"'.codeUnits; + chars.insertAll(block.start, insertion); + return String.fromCharCodes(chars); + } + + // No app block — insert one at the start of the "apps" block. + final insertion = + '\n\t\t\t\t"$appId"\n\t\t\t\t{\n\t\t\t\t\t"LaunchOptions"\t\t"$escapedValue"\n\t\t\t\t}' + .codeUnits; + chars.insertAll(apps.start, insertion); + return String.fromCharCodes(chars); + } + + /// Returns [text] with app [appId]'s LaunchOptions blanked, or null if there + /// is nothing of ours to clear (no block, no value, or a value that doesn't + /// reference one of our wrappers). + static String? clearLaunchOptionsInVdf(String text, {required String appId}) { + final chars = List.of(text.codeUnits); + + final apps = _blockRange(chars, 'apps', 0, chars.length); + if (apps == null) return null; + final block = _blockRange(chars, appId, apps.start, apps.end); + if (block == null) return null; + final valueRange = _launchOptionsValueRange(chars, block.start, block.end); + if (valueRange == null) return null; + + final current = String.fromCharCodes( + chars.sublist(valueRange.start, valueRange.end), + ); + if (!current.contains('setup_helper.sh') && + !current.contains('WINEDLLOVERRIDES')) { + return null; + } + + chars.replaceRange(valueRange.start, valueRange.end, '""'.codeUnits); + return String.fromCharCodes(chars); + } + + // MARK: - VDF scanning primitives + + /// Finds a `"key"` (case-insensitive) within `[rangeStart, rangeEnd)` that is + /// followed by a `{ … }` block, returning the body range (just after `{` up + /// to the matching `}`). The key appears many times in localconfig.vdf + /// (key/value pairs, hex blobs); only some are blocks, so scan every match. + static _Range? _blockRange( + List chars, + String key, + int rangeStart, + int rangeEnd, + ) { + final needle = [_quote, ...key.toLowerCase().codeUnits, _quote]; + final n = needle.length; + if (rangeEnd - rangeStart < n) return null; + + var i = rangeStart; + while (i <= rangeEnd - n) { + if (chars[i] != _quote) { + i++; + continue; + } + var k = 0; + while (k < n && _lower(chars[i + k]) == needle[k]) { + k++; + } + if (k != n) { + i++; + continue; + } + + // After the key, the next non-whitespace char must be `{`. + var j = i + n; + while (j < chars.length && + (chars[j] == _space || + chars[j] == _tab || + chars[j] == _nl || + chars[j] == _cr)) { + j++; + } + if (j < chars.length && chars[j] == _openBrace) { + final body = _matchBraces(chars, j); + if (body != null) return body; + } + i++; + } + return null; + } + + /// Given the index of `{`, returns the body range (after `{` to its match + /// `}`), honoring quoted strings so braces inside values don't miscount. + static _Range? _matchBraces(List chars, int openBrace) { + final bodyStart = openBrace + 1; + var depth = 1; + var j = bodyStart; + while (j < chars.length) { + final c = chars[j]; + if (c == _quote) { + j = _skipQuoted(chars, j); + continue; + } + if (c == _openBrace) { + depth++; + } else if (c == _closeBrace) { + depth--; + if (depth == 0) return _Range(bodyStart, j); + } + j++; + } + return null; + } + + /// Within `[bodyStart, bodyEnd)`, finds `"LaunchOptions"` (case-insensitive) + /// and returns the range of its value token, including surrounding quotes. + static _Range? _launchOptionsValueRange( + List chars, + int bodyStart, + int bodyEnd, + ) { + final needle = [_quote, ...'launchoptions'.codeUnits, _quote]; + final n = needle.length; + var i = bodyStart; + while (i <= bodyEnd - n) { + if (chars[i] == _quote) { + var k = 0; + while (k < n && _lower(chars[i + k]) == needle[k]) { + k++; + } + if (k == n) { + // Skip whitespace to the value's opening quote. + var v = i + n; + while (v < bodyEnd && (chars[v] == _space || chars[v] == _tab)) { + v++; + } + if (v >= bodyEnd || chars[v] != _quote) return null; + final end = _skipQuoted(chars, v); // index just past closing quote + return _Range(v, end); + } + } + i++; + } + return null; + } + + /// Given the index of an opening `"`, returns the index just past the closing + /// `"`, honoring `\"` escapes. + static int _skipQuoted(List chars, int openQuote) { + var i = openQuote + 1; + while (i < chars.length) { + if (chars[i] == _backslash) { + i += 2; + continue; + } + if (chars[i] == _quote) return i + 1; + i++; + } + return i; + } + + // MARK: - Filesystem helpers + + /// Every existing `userdata//config/localconfig.vdf` across all detected + /// Steam install roots, de-duplicated. + static List _localConfigPaths() { + final paths = {}; + for (final root in _steamRoots()) { + final userdata = Directory(p.join(root, 'userdata')); + if (!userdata.existsSync()) continue; + try { + for (final entity in userdata.listSync(followLinks: false)) { + if (entity is Directory) { + final cfg = File(p.join(entity.path, 'config', 'localconfig.vdf')); + if (cfg.existsSync()) paths.add(cfg.path); + } + } + } catch (_) {} + } + return paths.toList(); + } + + /// Candidate Steam install roots per platform (mirrors the detection used for + /// locating game installs elsewhere in the app). + static List _steamRoots() { + final env = Platform.environment; + final home = env['HOME'] ?? env['USERPROFILE'] ?? ''; + final roots = []; + + if (Platform.isWindows) { + final pf86 = env['ProgramFiles(x86)'] ?? env['PROGRAMFILES(X86)']; + final pf = env['ProgramFiles']; + if (pf86 != null && pf86.isNotEmpty) roots.add(p.join(pf86, 'Steam')); + if (pf != null && pf.isNotEmpty) roots.add(p.join(pf, 'Steam')); + roots.add(r'C:\Program Files (x86)\Steam'); + roots.add(r'C:\Program Files\Steam'); + } else if (Platform.isMacOS) { + roots.add(p.join(home, 'Library', 'Application Support', 'Steam')); + } else { + roots.add(p.join(home, '.steam', 'steam')); + roots.add(p.join(home, '.steam', 'root')); + roots.add(p.join(home, '.local', 'share', 'Steam')); + roots.add(p.join( + home, + '.var', + 'app', + 'com.valvesoftware.Steam', + '.local', + 'share', + 'Steam', + )); + } + + return roots + .where((r) => r.isNotEmpty && Directory(r).existsSync()) + .toSet() + .toList(); + } + + /// Writes [newText] to [path] atomically (temp file + rename) after taking a + /// one-shot backup. Returns true on success. + static Future _backupAndWrite(String path, String newText) async { + try { + final backup = File('$path.modlist.bak'); + if (backup.existsSync()) await backup.delete(); + await File(path).copy(backup.path); + } catch (_) { + // A missing backup is non-fatal; proceed with the write. + } + try { + final tmp = '$path.modlist.tmp'; + await File(tmp).writeAsString(newText, flush: true); + await File(tmp).rename(path); + return true; + } catch (_) { + return false; + } + } +} + +class _Range { + final int start; + final int end; + const _Range(this.start, this.end); +} diff --git a/lib/src/ui/installed_tab.dart b/lib/src/ui/installed_tab.dart index cf19a61..10e5c79 100644 --- a/lib/src/ui/installed_tab.dart +++ b/lib/src/ui/installed_tab.dart @@ -433,6 +433,17 @@ class _InstalledTabState extends State { widget.state.t('installed_launch_guide_title'), style: const TextStyle(color: Color(0xFF919AFF), fontSize: 13.0, fontWeight: FontWeight.bold), ), + // Auto-set confirmation (when we wrote localconfig.vdf). + if (widget.state.steamLaunchOptionsApplied) ...[ + const SizedBox(height: 8.0), + Text( + widget.state.steamLaunchOptionsNeedRestart + ? '${widget.state.t('installed_launch_auto_set')} ' + '${widget.state.t('installed_launch_auto_set_restart')}' + : widget.state.t('installed_launch_auto_set'), + style: const TextStyle(color: Color(0xFF7CE38B), fontSize: 12.0, height: 1.4, fontWeight: FontWeight.w600), + ), + ], const SizedBox(height: 8.0), Text( launchGuide, diff --git a/test/steam_config_test.dart b/test/steam_config_test.dart new file mode 100644 index 0000000..bed345c --- /dev/null +++ b/test/steam_config_test.dart @@ -0,0 +1,200 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:modlist_org_app/src/core/steam_config.dart'; + +/// Wraps an `apps` block body in the rest of a minimal localconfig.vdf tree. +String wrap(String appsBody) => + '"UserLocalConfigStore"\n' + '{\n' + '\t"Software"\n' + '\t{\n' + '\t\t"Valve"\n' + '\t\t{\n' + '\t\t\t"Steam"\n' + '\t\t\t{\n' + '\t\t\t\t"apps"\n' + '\t\t\t\t{\n' + '$appsBody\n' + '\t\t\t\t}\n' + '\t\t\t}\n' + '\t\t}\n' + '\t}\n' + '}\n'; + +/// An app block with the given id and an explicit LaunchOptions value. +String appWithLaunch(String id, String value) => + '\t\t\t\t\t"$id"\n' + '\t\t\t\t\t{\n' + '\t\t\t\t\t\t"LastPlayed"\t\t"1700000000"\n' + '\t\t\t\t\t\t"LaunchOptions"\t\t"$value"\n' + '\t\t\t\t\t}'; + +/// An app block with the given id and no LaunchOptions key. +String appNoLaunch(String id) => + '\t\t\t\t\t"$id"\n' + '\t\t\t\t\t{\n' + '\t\t\t\t\t\t"LastPlayed"\t\t"1700000001"\n' + '\t\t\t\t\t}'; + +void main() { + group('SteamConfig.setLaunchOptionsInVdf', () { + test('replaces an existing LaunchOptions value in the target app block', () { + final vdf = wrap(appWithLaunch('977950', 'OLDVALUE')); + final out = SteamConfig.setLaunchOptionsInVdf( + vdf, + appId: '977950', + escapedValue: 'NEWVAL', + ); + expect(out, isNotNull); + expect(out, contains('"NEWVAL"')); + expect(out, isNot(contains('OLDVALUE'))); + // Exactly one LaunchOptions value, not duplicated. + expect('NEWVAL'.allMatches(out!).length, 1); + }); + + test('inserts LaunchOptions when the app block has none', () { + final vdf = wrap(appNoLaunch('774181')); + final out = SteamConfig.setLaunchOptionsInVdf( + vdf, + appId: '774181', + escapedValue: 'NEWVAL', + ); + expect(out, isNotNull); + expect(out, contains('"LaunchOptions"')); + expect(out, contains('"NEWVAL"')); + // The pre-existing key in the block is preserved. + expect(out, contains('"LastPlayed"')); + }); + + test('inserts a new app block when the id is absent', () { + final vdf = wrap(appWithLaunch('111111', 'OTHER')); + final out = SteamConfig.setLaunchOptionsInVdf( + vdf, + appId: '977950', + escapedValue: 'NEWVAL', + ); + expect(out, isNotNull); + expect(out, contains('"977950"')); + expect(out, contains('"NEWVAL"')); + // The unrelated existing app is left intact. + expect(out, contains('"111111"')); + expect(out, contains('OTHER')); + }); + + test('returns null when there is no apps block', () { + const vdf = '"UserLocalConfigStore"\n{\n\t"Friends"\n\t{\n\t}\n}\n'; + final out = SteamConfig.setLaunchOptionsInVdf( + vdf, + appId: '977950', + escapedValue: 'NEWVAL', + ); + expect(out, isNull); + }); + + test('only edits the id inside the apps block, ignoring decoys outside', () { + // A stray "977950" block sits in an unrelated section before apps. + final vdf = '"UserLocalConfigStore"\n' + '{\n' + '\t"apptickets"\n' + '\t{\n' + '\t\t"977950"\n' + '\t\t{\n' + '\t\t\t"LaunchOptions"\t\t"DECOY"\n' + '\t\t}\n' + '\t}\n' + '${_appsTree(appWithLaunch('977950', 'REALOLD'))}' + '}\n'; + final out = SteamConfig.setLaunchOptionsInVdf( + vdf, + appId: '977950', + escapedValue: 'NEWVAL', + ); + expect(out, isNotNull); + // The real in-apps value is replaced... + expect(out, contains('"NEWVAL"')); + expect(out, isNot(contains('REALOLD'))); + // ...and the decoy outside apps is untouched. + expect(out, contains('DECOY')); + }); + + test('is case-insensitive for Apps and LaunchOptions keys', () { + final vdf = wrap( + '\t\t\t\t\t"977950"\n' + '\t\t\t\t\t{\n' + '\t\t\t\t\t\t"launchoptions"\t\t"OLDVALUE"\n' + '\t\t\t\t\t}', + ).replaceFirst('"apps"', '"Apps"'); + final out = SteamConfig.setLaunchOptionsInVdf( + vdf, + appId: '977950', + escapedValue: 'NEWVAL', + ); + expect(out, isNotNull); + expect(out, contains('"NEWVAL"')); + expect(out, isNot(contains('OLDVALUE'))); + }); + + test('locates the value past escaped quotes (macOS-style value)', () { + // Simulate a value already containing escaped quotes, then re-set it. + final escaped = r'\"/Users/me/game/setup_helper.sh\" %command%'; + final vdf = wrap(appWithLaunch('977950', escaped)); + final out = SteamConfig.setLaunchOptionsInVdf( + vdf, + appId: '977950', + escapedValue: 'REPLACED', + ); + expect(out, isNotNull); + expect(out, contains('"REPLACED"')); + expect(out, isNot(contains('setup_helper.sh'))); + // The trailing %command% from the old value must be gone too. + expect('%command%'.allMatches(out!).length, 0); + }); + }); + + group('SteamConfig.clearLaunchOptionsInVdf', () { + test('blanks our wrapper value', () { + final vdf = wrap(appWithLaunch('977950', './setup_helper.sh %command%')); + final out = SteamConfig.clearLaunchOptionsInVdf(vdf, appId: '977950'); + expect(out, isNotNull); + expect(out, isNot(contains('setup_helper.sh'))); + expect(out, contains('"LaunchOptions"\t\t""')); + }); + + test('blanks a Proton WINEDLLOVERRIDES wrapper value', () { + final vdf = wrap( + appWithLaunch('4395300', r'WINEDLLOVERRIDES=\"winhttp=n,b\" %command%'), + ); + final out = SteamConfig.clearLaunchOptionsInVdf(vdf, appId: '4395300'); + expect(out, isNotNull); + expect(out, isNot(contains('WINEDLLOVERRIDES'))); + }); + + test('leaves a user custom (non-wrapper) value untouched', () { + final vdf = wrap(appWithLaunch('977950', 'gamemoderun %command%')); + final out = SteamConfig.clearLaunchOptionsInVdf(vdf, appId: '977950'); + expect(out, isNull); + }); + + test('returns null when the app block is absent', () { + final vdf = wrap(appWithLaunch('111111', './setup_helper.sh %command%')); + final out = SteamConfig.clearLaunchOptionsInVdf(vdf, appId: '977950'); + expect(out, isNull); + }); + }); +} + +/// Builds just the Software→Valve→Steam→apps subtree (used to compose a file +/// that also has unrelated sections alongside it). +String _appsTree(String appsBody) => + '\t"Software"\n' + '\t{\n' + '\t\t"Valve"\n' + '\t\t{\n' + '\t\t\t"Steam"\n' + '\t\t\t{\n' + '\t\t\t\t"apps"\n' + '\t\t\t\t{\n' + '$appsBody\n' + '\t\t\t\t}\n' + '\t\t\t}\n' + '\t\t}\n' + '\t}\n';