diff --git a/lib/common/conf/conf.dart b/lib/common/conf/conf.dart index 280831d..885d8fa 100644 --- a/lib/common/conf/conf.dart +++ b/lib/common/conf/conf.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + class ConstConf { static const String appVersion = "3.0.0 Beta9"; static const int appVersionCode = 79; @@ -18,5 +20,12 @@ class AppConf { _networkGameChannels = channels; } - static List get gameChannels => _networkGameChannels ?? ConstConf._gameChannels; + static List get gameChannels { + final baseChannels = _networkGameChannels ?? ConstConf._gameChannels; + // On Linux, add lowercase variants for case-sensitive filesystem + if (Platform.isLinux) { + return [...baseChannels, ...baseChannels.map((c) => c.toLowerCase())]; + } + return baseChannels; + } } diff --git a/lib/common/helper/log_helper.dart b/lib/common/helper/log_helper.dart index 115693d..f6022e9 100644 --- a/lib/common/helper/log_helper.dart +++ b/lib/common/helper/log_helper.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:hive_ce/hive.dart'; import 'package:starcitizen_doctor/common/conf/conf.dart'; +import 'package:starcitizen_doctor/common/utils/base_utils.dart'; import 'package:starcitizen_doctor/common/utils/log.dart'; class SCLoggerHelper { @@ -43,20 +44,23 @@ class SCLoggerHelper { } } - static Future> getGameInstallPath(List listData, - {bool checkExists = true, - List withVersion = const ["LIVE"]}) async { + static Future> getGameInstallPath( + List listData, { + bool checkExists = true, + List withVersion = const ["LIVE"], + }) async { List scInstallPaths = []; checkAndAddPath(String path, bool checkExists) async { - // 将所有连续的 \\ 替换为 \ - path = path.replaceAll(RegExp(r'\\+'), "\\"); - if (path.isNotEmpty && !scInstallPaths.contains(path)) { + // Normalize path separators to current platform format + path = path.platformPath; + + // Case-insensitive check for existing paths + if (path.isNotEmpty && !scInstallPaths.any((p) => p.toLowerCase() == path.toLowerCase())) { if (!checkExists) { dPrint("find installPath == $path"); scInstallPaths.add(path); - } else if (await File("$path/Bin64/StarCitizen.exe").exists() && - await File("$path/Data.p4k").exists()) { + } else if (await File("$path/Bin64/StarCitizen.exe").exists() && await File("$path/Data.p4k").exists()) { dPrint("find installPath == $path"); scInstallPaths.add(path); } @@ -67,14 +71,14 @@ class SCLoggerHelper { final path = confBox.get("custom_game_path"); if (path != null && path != "") { for (var v in withVersion) { - await checkAndAddPath("$path\\$v", checkExists); + await checkAndAddPath("$path\\$v".platformPath, checkExists); } } try { for (var v in withVersion) { - String pattern = - r'([a-zA-Z]:\\\\[^\\\\]*\\\\[^\\\\]*\\\\StarCitizen\\\\' + v + r')'; + // Match both Windows (\\) and Unix (/) path separators in log entries, case-insensitive + String pattern = r'([a-zA-Z]:[\\/][^\\/]*[\\/][^\\/]*[\\/]StarCitizen[\\/]' + v + r')'; RegExp regExp = RegExp(pattern, caseSensitive: false); for (var i = listData.length - 1; i > 0; i--) { final line = listData[i]; @@ -89,10 +93,14 @@ class SCLoggerHelper { // 动态检测更多位置 for (var fileName in List.from(scInstallPaths)) { for (var v in withVersion) { - if (fileName.toString().endsWith(v)) { + final suffix = '\\$v'.platformPath.toLowerCase(); + if (fileName.toString().toLowerCase().endsWith(suffix)) { for (var nv in withVersion) { - final nextName = - "${fileName.toString().replaceAll("\\$v", "")}\\$nv"; + final basePath = fileName.toString().replaceAll( + RegExp('${RegExp.escape(suffix)}\$', caseSensitive: false), + '', + ); + final nextName = "$basePath\\$nv".platformPath; await checkAndAddPath(nextName, true); } } @@ -108,9 +116,10 @@ class SCLoggerHelper { } static String getGameChannelID(String installPath) { + final pathLower = installPath.platformPath.toLowerCase(); for (var value in AppConf.gameChannels) { - if (installPath.endsWith("\\$value")) { - return value; + if (pathLower.endsWith('\\${value.toLowerCase()}'.platformPath)) { + return value.toUpperCase(); } } return "UNKNOWN"; @@ -121,8 +130,7 @@ class SCLoggerHelper { if (!await logFile.exists()) { return null; } - return await logFile.readAsLines( - encoding: const Utf8Codec(allowMalformed: true)); + return await logFile.readAsLines(encoding: const Utf8Codec(allowMalformed: true)); } static MapEntry? getGameRunningLogInfo(List logs) { @@ -138,47 +146,47 @@ class SCLoggerHelper { static MapEntry? _checkRunningLine(String line) { if (line.contains("STATUS_CRYENGINE_OUT_OF_SYSMEM")) { - return MapEntry(S.current.doctor_game_error_low_memory, - S.current.doctor_game_error_low_memory_info); + return MapEntry(S.current.doctor_game_error_low_memory, S.current.doctor_game_error_low_memory_info); } if (line.contains("EXCEPTION_ACCESS_VIOLATION")) { - return MapEntry(S.current.doctor_game_error_generic_info, - "https://docs.qq.com/doc/DUURxUVhzTmZoY09Z"); + return MapEntry(S.current.doctor_game_error_generic_info, "https://docs.qq.com/doc/DUURxUVhzTmZoY09Z"); } if (line.contains("DXGI_ERROR_DEVICE_REMOVED")) { - return MapEntry(S.current.doctor_game_error_gpu_crash, - "https://www.bilibili.com/read/cv19335199"); + return MapEntry(S.current.doctor_game_error_gpu_crash, "https://www.bilibili.com/read/cv19335199"); } if (line.contains("Wakeup socket sendto error")) { - return MapEntry(S.current.doctor_game_error_socket_error, - S.current.doctor_game_error_socket_error_info); + return MapEntry(S.current.doctor_game_error_socket_error, S.current.doctor_game_error_socket_error_info); } if (line.contains("The requested operation requires elevated")) { - return MapEntry(S.current.doctor_game_error_permissions_error, - S.current.doctor_game_error_permissions_error_info); + return MapEntry( + S.current.doctor_game_error_permissions_error, + S.current.doctor_game_error_permissions_error_info, + ); } - if (line.contains( - "The process cannot access the file because is is being used by another process")) { - return MapEntry(S.current.doctor_game_error_game_process_error, - S.current.doctor_game_error_game_process_error_info); + if (line.contains("The process cannot access the file because is is being used by another process")) { + return MapEntry( + S.current.doctor_game_error_game_process_error, + S.current.doctor_game_error_game_process_error_info, + ); } if (line.contains("0xc0000043")) { - return MapEntry(S.current.doctor_game_error_game_damaged_file, - S.current.doctor_game_error_game_damaged_file_info); + return MapEntry( + S.current.doctor_game_error_game_damaged_file, + S.current.doctor_game_error_game_damaged_file_info, + ); } if (line.contains("option to verify the content of the Data.p4k file")) { - return MapEntry(S.current.doctor_game_error_game_damaged_p4k_file, - S.current.doctor_game_error_game_damaged_p4k_file_info); + return MapEntry( + S.current.doctor_game_error_game_damaged_p4k_file, + S.current.doctor_game_error_game_damaged_p4k_file_info, + ); } if (line.contains("OUTOFMEMORY Direct3D could not allocate")) { - return MapEntry(S.current.doctor_game_error_low_gpu_memory, - S.current.doctor_game_error_low_gpu_memory_info); + return MapEntry(S.current.doctor_game_error_low_gpu_memory, S.current.doctor_game_error_low_gpu_memory_info); } - if (line.contains( - "try disabling with r_vulkanDisableLayers = 1 in your user.cfg")) { - return MapEntry(S.current.doctor_game_error_gpu_vulkan_crash, - S.current.doctor_game_error_gpu_vulkan_crash_info); + if (line.contains("try disabling with r_vulkanDisableLayers = 1 in your user.cfg")) { + return MapEntry(S.current.doctor_game_error_gpu_vulkan_crash, S.current.doctor_game_error_gpu_vulkan_crash_info); } /// Unknown diff --git a/lib/common/utils/base_utils.dart b/lib/common/utils/base_utils.dart index 7a60871..309a68a 100644 --- a/lib/common/utils/base_utils.dart +++ b/lib/common/utils/base_utils.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/rendering.dart'; @@ -7,105 +8,118 @@ import 'dart:ui' as ui; import 'package:flutter/services.dart'; import 'package:starcitizen_doctor/generated/l10n.dart'; -Future showToast(BuildContext context, String msg, {BoxConstraints? constraints, String? title}) async { - return showBaseDialog(context, - title: title ?? S.current.app_common_tip, - content: Text(msg), - actions: [ - FilledButton( - child: Padding( - padding: const EdgeInsets.only(top: 2, bottom: 2, left: 8, right: 8), - child: Text(S.current.app_common_tip_i_know), - ), - onPressed: () => Navigator.pop(context), - ), - ], - constraints: constraints); +/// String extension for cross-platform path compatibility +extension PathStringExtension on String { + /// Converts path separators to the current platform's format. + /// On Windows: / -> \ + /// On Linux/macOS: \ -> / + String get platformPath { + if (Platform.isWindows) { + return replaceAll('/', '\\'); + } + return replaceAll('\\', '/'); + } } -Future showConfirmDialogs(BuildContext context, String title, Widget content, - {String confirm = "", String cancel = "", BoxConstraints? constraints}) async { +Future showToast(BuildContext context, String msg, {BoxConstraints? constraints, String? title}) async { + return showBaseDialog( + context, + title: title ?? S.current.app_common_tip, + content: Text(msg), + actions: [ + FilledButton( + child: Padding( + padding: const EdgeInsets.only(top: 2, bottom: 2, left: 8, right: 8), + child: Text(S.current.app_common_tip_i_know), + ), + onPressed: () => Navigator.pop(context), + ), + ], + constraints: constraints, + ); +} + +Future showConfirmDialogs( + BuildContext context, + String title, + Widget content, { + String confirm = "", + String cancel = "", + BoxConstraints? constraints, +}) async { if (confirm.isEmpty) confirm = S.current.app_common_tip_confirm; if (cancel.isEmpty) cancel = S.current.app_common_tip_cancel; - final r = await showBaseDialog(context, - title: title, - content: content, - actions: [ - if (confirm.isNotEmpty) - FilledButton( - child: Padding( - padding: const EdgeInsets.only(top: 2, bottom: 2, left: 8, right: 8), - child: Text(confirm), - ), - onPressed: () => Navigator.pop(context, true), - ), - if (cancel.isNotEmpty) - Button( - child: Padding( - padding: const EdgeInsets.only(top: 2, bottom: 2, left: 8, right: 8), - child: Text(cancel), - ), - onPressed: () => Navigator.pop(context, false), - ), - ], - constraints: constraints); + final r = await showBaseDialog( + context, + title: title, + content: content, + actions: [ + if (confirm.isNotEmpty) + FilledButton( + child: Padding(padding: const EdgeInsets.only(top: 2, bottom: 2, left: 8, right: 8), child: Text(confirm)), + onPressed: () => Navigator.pop(context, true), + ), + if (cancel.isNotEmpty) + Button( + child: Padding(padding: const EdgeInsets.only(top: 2, bottom: 2, left: 8, right: 8), child: Text(cancel)), + onPressed: () => Navigator.pop(context, false), + ), + ], + constraints: constraints, + ); return r == true; } -Future showInputDialogs(BuildContext context, - {required String title, - required String content, - BoxConstraints? constraints, - String? initialValue, - List? inputFormatters}) async { +Future showInputDialogs( + BuildContext context, { + required String title, + required String content, + BoxConstraints? constraints, + String? initialValue, + List? inputFormatters, +}) async { String? userInput; - constraints ??= BoxConstraints(maxWidth: MediaQuery - .of(context) - .size - .width * .38); + constraints ??= BoxConstraints(maxWidth: MediaQuery.of(context).size.width * .38); final ok = await showConfirmDialogs( - context, - title, - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (content.isNotEmpty) - Text( - content, - style: TextStyle(color: Colors.white.withValues(alpha: .6)), - ), - const SizedBox(height: 8), - TextFormBox( - initialValue: initialValue, - onChanged: (str) { - userInput = str; - }, - inputFormatters: inputFormatters, - ), - ], - ), - constraints: constraints); + context, + title, + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (content.isNotEmpty) Text(content, style: TextStyle(color: Colors.white.withValues(alpha: .6))), + const SizedBox(height: 8), + TextFormBox( + initialValue: initialValue, + onChanged: (str) { + userInput = str; + }, + inputFormatters: inputFormatters, + ), + ], + ), + constraints: constraints, + ); if (ok == true) return userInput; return null; } -Future showBaseDialog(BuildContext context, - {required String title, required Widget content, List? actions, BoxConstraints? constraints}) async { +Future showBaseDialog( + BuildContext context, { + required String title, + required Widget content, + List? actions, + BoxConstraints? constraints, +}) async { return await showDialog( context: context, - builder: (context) => - ContentDialog( - title: Text(title), - content: content, - constraints: constraints ?? - const BoxConstraints( - maxWidth: 512, - maxHeight: 756.0, - ), - actions: actions, - ), + builder: (context) => ContentDialog( + title: Text(title), + content: content, + constraints: constraints ?? const BoxConstraints(maxWidth: 512, maxHeight: 756.0), + actions: actions, + ), ); } diff --git a/lib/ui/guide/guide_ui.dart b/lib/ui/guide/guide_ui.dart index d6f5a46..5d83e6b 100644 --- a/lib/ui/guide/guide_ui.dart +++ b/lib/ui/guide/guide_ui.dart @@ -36,17 +36,9 @@ class GuideUI extends HookConsumerWidget { alignment: AlignmentDirectional.centerStart, child: Row( children: [ - Image.asset( - "assets/app_logo_mini.png", - width: 20, - height: 20, - fit: BoxFit.cover, - ), + Image.asset("assets/app_logo_mini.png", width: 20, height: 20, fit: BoxFit.cover), const SizedBox(width: 12), - Text( - S.current.app_index_version_info( - ConstConf.appVersion, ConstConf.isMSE ? "" : " Dev"), - ) + Text(S.current.app_index_version_info(ConstConf.appVersion, ConstConf.isMSE ? "" : " Dev")), ], ), ), @@ -56,42 +48,34 @@ class GuideUI extends HookConsumerWidget { children: [ Image.asset("assets/app_logo.png", width: 192, height: 192), SizedBox(height: 12), - Text( - S.current.guide_title_welcome, - style: TextStyle( - fontSize: 38, - ), - ), + Text(S.current.guide_title_welcome, style: TextStyle(fontSize: 38)), SizedBox(height: 24), Text(S.current.guide_info_check_settings), SizedBox(height: 32), Container( - padding: EdgeInsets.symmetric(horizontal: 32), - child: Row( - children: [ - Expanded( - child: Column( - children: [ - makeGameLauncherPathSelect( - context, toolsModel, toolsState, settingModel), - const SizedBox(height: 12), - makeGamePathSelect( - context, toolsModel, toolsState, settingModel), - ], - ), + padding: EdgeInsets.symmetric(horizontal: 32), + child: Row( + children: [ + Expanded( + child: Column( + children: [ + makeGameLauncherPathSelect(context, toolsModel, toolsState, settingModel), + const SizedBox(height: 12), + makeGamePathSelect(context, toolsModel, toolsState, settingModel), + ], ), - SizedBox(width: 12), - Button( - onPressed: () => toolsModel.reScanPath(context, - checkActive: true, skipToast: true), - child: const Padding( - padding: EdgeInsets.only( - top: 30, bottom: 30, left: 12, right: 12), - child: Icon(FluentIcons.refresh), - ), + ), + SizedBox(width: 12), + Button( + onPressed: () => toolsModel.reScanPath(context, checkActive: true, skipToast: true), + child: const Padding( + padding: EdgeInsets.only(top: 30, bottom: 30, left: 12, right: 12), + child: Icon(FluentIcons.refresh), ), - ], - )), + ), + ], + ), + ), SizedBox(height: 12), Padding( padding: const EdgeInsets.only(right: 32, left: 32), @@ -100,9 +84,7 @@ class GuideUI extends HookConsumerWidget { Expanded( child: Text( S.current.guide_info_game_download_note, - style: TextStyle( - fontSize: 12, - color: Colors.white.withValues(alpha: .6)), + style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: .6)), textAlign: TextAlign.end, ), ), @@ -115,8 +97,7 @@ class GuideUI extends HookConsumerWidget { Spacer(), Button( child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Text(S.current.guide_action_get_help), ), onPressed: () { @@ -126,26 +107,25 @@ class GuideUI extends HookConsumerWidget { SizedBox(width: 24), FilledButton( child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Text(S.current.guide_action_complete_setup), ), onPressed: () async { if (toolsState.rsiLauncherInstallPaths.isEmpty) { final ok = await showConfirmDialogs( - context, - S.current.guide_dialog_confirm_complete_setup, - Text(S.current - .guide_action_info_no_launcher_path_warning)); + context, + S.current.guide_dialog_confirm_complete_setup, + Text(S.current.guide_action_info_no_launcher_path_warning), + ); if (!ok) return; } if (toolsState.scInstallPaths.isEmpty) { if (!context.mounted) return; final ok = await showConfirmDialogs( - context, - S.current.guide_dialog_confirm_complete_setup, - Text(S - .current.guide_action_info_no_game_path_warning)); + context, + S.current.guide_dialog_confirm_complete_setup, + Text(S.current.guide_action_info_no_game_path_warning), + ); if (!ok) return; } final appConf = await Hive.openBox("app_conf"); @@ -164,8 +144,12 @@ class GuideUI extends HookConsumerWidget { ); } - Widget makeGameLauncherPathSelect(BuildContext context, ToolsUIModel model, - ToolsUIState state, SettingsUIModel settingModel) { + Widget makeGameLauncherPathSelect( + BuildContext context, + ToolsUIModel model, + ToolsUIState state, + SettingsUIModel settingModel, + ) { return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -177,13 +161,7 @@ class GuideUI extends HookConsumerWidget { child: ComboBox( isExpanded: true, value: state.rsiLauncherInstalledPath, - items: [ - for (final path in state.rsiLauncherInstallPaths) - ComboBoxItem( - value: path, - child: Text(path), - ) - ], + items: [for (final path in state.rsiLauncherInstallPaths) ComboBoxItem(value: path, child: Text(path))], onChanged: (v) { model.onChangeLauncherPath(v!); }, @@ -192,10 +170,7 @@ class GuideUI extends HookConsumerWidget { ), const SizedBox(width: 8), Button( - child: const Padding( - padding: EdgeInsets.all(6), - child: Icon(FluentIcons.folder_search), - ), + child: const Padding(padding: EdgeInsets.all(6), child: Icon(FluentIcons.folder_search)), onPressed: () async { await settingModel.setLauncherPath(context); if (!context.mounted) return; @@ -206,8 +181,12 @@ class GuideUI extends HookConsumerWidget { ); } - Widget makeGamePathSelect(BuildContext context, ToolsUIModel model, - ToolsUIState state, SettingsUIModel settingModel) { + Widget makeGamePathSelect( + BuildContext context, + ToolsUIModel model, + ToolsUIState state, + SettingsUIModel settingModel, + ) { return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -219,13 +198,7 @@ class GuideUI extends HookConsumerWidget { child: ComboBox( isExpanded: true, value: state.scInstalledPath, - items: [ - for (final path in state.scInstallPaths) - ComboBoxItem( - value: path, - child: Text(path), - ) - ], + items: [for (final path in state.scInstallPaths) ComboBoxItem(value: path, child: Text(path))], onChanged: (v) { model.onChangeGamePath(v!); }, @@ -234,15 +207,13 @@ class GuideUI extends HookConsumerWidget { ), const SizedBox(width: 8), Button( - child: const Padding( - padding: EdgeInsets.all(6), - child: Icon(FluentIcons.folder_search), - ), - onPressed: () async { - await settingModel.setGamePath(context); - if (!context.mounted) return; - model.reScanPath(context, checkActive: true, skipToast: true); - }) + child: const Padding(padding: EdgeInsets.all(6), child: Icon(FluentIcons.folder_search)), + onPressed: () async { + await settingModel.setGamePath(context); + if (!context.mounted) return; + model.reScanPath(context, checkActive: true, skipToast: true); + }, + ), ], ); } diff --git a/lib/ui/home/dialogs/home_game_login_dialog_ui_model.dart b/lib/ui/home/dialogs/home_game_login_dialog_ui_model.dart index e188619..d2928ca 100644 --- a/lib/ui/home/dialogs/home_game_login_dialog_ui_model.dart +++ b/lib/ui/home/dialogs/home_game_login_dialog_ui_model.dart @@ -216,7 +216,8 @@ class HomeGameLoginUIModel extends _$HomeGameLoginUIModel { } String getChannelID(String installPath) { - if (installPath.endsWith("\\LIVE")) { + final pathLower = installPath.platformPath.toLowerCase(); + if (pathLower.endsWith('\\live'.platformPath)) { return "LIVE"; } return "PTU"; diff --git a/lib/ui/settings/settings_ui_model.dart b/lib/ui/settings/settings_ui_model.dart index 09970e3..798f90d 100644 --- a/lib/ui/settings/settings_ui_model.dart +++ b/lib/ui/settings/settings_ui_model.dart @@ -80,8 +80,8 @@ class SettingsUIModel extends _$SettingsUIModel { lockParentWindow: true, ); if (r == null || r.files.firstOrNull?.path == null) return; - final fileName = r.files.first.path!; - if (fileName.endsWith("\\RSI Launcher.exe")) { + final fileName = r.files.first.path!.platformPath; + if (fileName.toLowerCase().endsWith('\\rsi launcher.exe'.platformPath)) { await _saveCustomPath("custom_launcher_path", fileName); if (!context.mounted) return; showToast(context, S.current.setting_action_info_setting_success); @@ -101,11 +101,14 @@ class SettingsUIModel extends _$SettingsUIModel { lockParentWindow: true, ); if (r == null || r.files.firstOrNull?.path == null) return; - final fileName = r.files.first.path!; + final fileName = r.files.first.path!.platformPath; dPrint(fileName); - final fileNameRegExp = RegExp(r"^(.*\\StarCitizen\\.*\\)Bin64\\StarCitizen\.exe$", caseSensitive: false); + final fileNameRegExp = RegExp( + r'^(.*\\StarCitizen\\.*\\)Bin64\\StarCitizen\.exe$'.platformPath, + caseSensitive: false, + ); if (fileNameRegExp.hasMatch(fileName)) { - RegExp pathRegex = RegExp(r"\\[^\\]+\\Bin64\\StarCitizen\.exe$"); + RegExp pathRegex = RegExp(r'\\[^\\]+\\Bin64\\StarCitizen\.exe$'.platformPath, caseSensitive: false); String extractedPath = fileName.replaceFirst(pathRegex, ''); await _saveCustomPath("custom_game_path", extractedPath); if (!context.mounted) return;