MainViewModel.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. using System.Collections.ObjectModel;
  2. using System.Text.Json.Nodes;
  3. using Avalonia.Media;
  4. using CommunityToolkit.Mvvm.ComponentModel;
  5. using CommunityToolkit.Mvvm.Input;
  6. using PCL.Communist.Core;
  7. namespace PCL.Communist.ViewModels;
  8. public sealed record LauncherTheme(string Name, string Color)
  9. {
  10. public override string ToString() => Name;
  11. }
  12. public partial class MainViewModel : ViewModelBase
  13. {
  14. private readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(3) };
  15. private readonly PlatformInfo _platform;
  16. private LauncherPaths _paths;
  17. private readonly string _settingsPath;
  18. private CancellationTokenSource? _operation;
  19. private AccountSession? _account;
  20. public MainViewModel() : this(PlatformInfo.Current, LauncherPaths.Default) { }
  21. public MainViewModel(PlatformInfo platform, LauncherPaths paths)
  22. {
  23. _platform = platform;
  24. _paths = paths;
  25. _settingsPath = Path.Combine(Path.GetDirectoryName(paths.Root)!, "launcher-settings.json");
  26. GameRoot = paths.Root;
  27. _http.DefaultRequestHeaders.UserAgent.ParseAdd("PCL-Communist/0.1.0");
  28. SelectedTheme = Themes[0];
  29. LoadSettings();
  30. LoadInstalledVersions();
  31. }
  32. public string PlatformName => _platform.DisplayName;
  33. public bool IsDesktop => !_platform.IsMobile;
  34. public string RuntimeDescription => Runtime.Capability.Description;
  35. public string LaunchLabel => IsDesktop ? "启动游戏 →" : "本机运行时待接入";
  36. public string MobileNotice => "资源可以在本机下载和校验。游戏画面、Java 和触控运行时尚待接入,当前构建暂不能在手机或 iPad 上启动游戏。";
  37. public bool IsMobile => _platform.IsMobile;
  38. public ObservableCollection<MinecraftVersion> Versions { get; } = [];
  39. public IReadOnlyList<LauncherTheme> Themes { get; } =
  40. [
  41. new("苏维埃红", "#C93746"), new("天空蓝", "#3E87CD"), new("秋叶金", "#B98A35"),
  42. new("清水绿", "#348F86"), new("森林绿", "#4C9560"), new("星空紫", "#8062BE"),
  43. new("樱花粉", "#C96794"), new("落日橙", "#D7753B"), new("深海蓝", "#425AB0"),
  44. new("薄荷青", "#329D9C"), new("玫瑰红", "#BA476F"), new("丁香紫", "#A175BF"),
  45. new("琥珀金", "#AD7C2A"), new("青瓷绿", "#509285"), new("冰川蓝", "#548DB2"),
  46. new("石墨灰", "#78818F"), new("午夜紫", "#5F4D99")
  47. ];
  48. [ObservableProperty]
  49. [NotifyCanExecuteChangedFor(nameof(InstallCommand))]
  50. [NotifyCanExecuteChangedFor(nameof(LaunchCommand))]
  51. public partial MinecraftVersion? SelectedVersion { get; set; }
  52. [ObservableProperty]
  53. [NotifyCanExecuteChangedFor(nameof(RefreshCommand))]
  54. [NotifyCanExecuteChangedFor(nameof(InstallCommand))]
  55. [NotifyCanExecuteChangedFor(nameof(LaunchCommand))]
  56. [NotifyCanExecuteChangedFor(nameof(LoginCommand))]
  57. [NotifyCanExecuteChangedFor(nameof(LogoutCommand))]
  58. [NotifyCanExecuteChangedFor(nameof(SaveSettingsCommand))]
  59. public partial bool IsBusy { get; set; }
  60. [ObservableProperty] public partial string Status { get; set; } = "选择已安装版本,或刷新列表下载游戏。";
  61. [ObservableProperty] public partial double Progress { get; set; }
  62. [ObservableProperty] public partial string PlayerName { get; set; } = "Player";
  63. [ObservableProperty] public partial string AccountStatus { get; set; } = "离线账号 · 可登录微软正版账号";
  64. [ObservableProperty] public partial string LoginCode { get; set; } = "";
  65. [ObservableProperty] public partial bool ShowLoginCode { get; set; }
  66. [ObservableProperty] public partial string LoginAddress { get; set; } = "https://microsoft.com/link";
  67. [ObservableProperty] public partial string GameRoot { get; set; } = "";
  68. [ObservableProperty] public partial string JavaPath { get; set; } = "";
  69. [ObservableProperty] public partial decimal MemoryMb { get; set; } = 2048;
  70. [ObservableProperty] public partial LauncherTheme SelectedTheme { get; set; } = null!;
  71. public IBrush AccentBrush => Brush.Parse(SelectedTheme?.Color ?? "#C93746");
  72. partial void OnSelectedThemeChanged(LauncherTheme value) => OnPropertyChanged(nameof(AccentBrush));
  73. private bool CanWork() => !IsBusy;
  74. private bool CanInstall() => !IsBusy && SelectedVersion is not null;
  75. private bool CanLaunch() => CanInstall() && IsDesktop;
  76. private DownloadClient Downloads => new(_http);
  77. private MinecraftInstaller Installer => new(Downloads, _paths);
  78. private GameLauncher Runtime => new(_platform, _paths, new JavaRuntimeService(Downloads, _paths));
  79. private IProgress<InstallProgress> InstallProgress => new Progress<InstallProgress>(p => { Status = p.Message; Progress = p.Percent; });
  80. [RelayCommand(CanExecute = nameof(CanWork))]
  81. private Task RefreshAsync() => RunAsync(async token =>
  82. {
  83. Status = "获取可用版本…";
  84. var versions = await Installer.GetVersionsAsync(token);
  85. var selected = SelectedVersion?.Id;
  86. Versions.Clear();
  87. foreach (var version in versions.Where(v => v.Type == "release")) Versions.Add(version);
  88. SelectedVersion = Versions.FirstOrDefault(v => v.Id == selected) ?? Versions.FirstOrDefault();
  89. Status = $"已获取 {Versions.Count} 个正式版本。";
  90. });
  91. [RelayCommand(CanExecute = nameof(CanInstall))]
  92. private Task InstallAsync() => RunAsync(async token =>
  93. {
  94. ApplySettings();
  95. if (SelectedVersion is { Url.Length: 0 } local)
  96. await Installer.RepairAsync(local.Id, _platform, InstallProgress, token);
  97. else await InstallSelectedAsync(token);
  98. });
  99. private async Task<JsonObject> InstallSelectedAsync(CancellationToken token)
  100. {
  101. var version = SelectedVersion ?? throw new InvalidOperationException("请先选择游戏版本。");
  102. // Complete cached installations work offline; interrupted downloads are repaired before launching.
  103. if (string.IsNullOrEmpty(version.Url))
  104. return await Installer.RepairAsync(version.Id, _platform, InstallProgress, token);
  105. return await Installer.InstallAsync(version, _platform, InstallProgress, token);
  106. }
  107. [RelayCommand(CanExecute = nameof(CanLaunch))]
  108. private Task LaunchAsync() => RunAsync(async token =>
  109. {
  110. ApplySettings();
  111. var account = _account ?? AccountSession.Offline(PlayerName.Trim());
  112. var metadata = await InstallSelectedAsync(token);
  113. Status = "准备游戏运行时…";
  114. await Runtime.LaunchAsync(metadata, account, JavaPath.Trim(), (int)MemoryMb, InstallProgress, token);
  115. Status = "游戏进程已启动。";
  116. });
  117. [RelayCommand(CanExecute = nameof(CanWork))]
  118. private Task LoginAsync() => RunAsync(async token =>
  119. {
  120. Status = "正在准备微软登录…";
  121. try
  122. {
  123. _account = await new MicrosoftLogin(_http).LoginAsync(prompt =>
  124. {
  125. LoginCode = prompt.Code;
  126. LoginAddress = prompt.VerificationUri.AbsoluteUri;
  127. ShowLoginCode = true;
  128. Status = "打开登录网页,输入上方代码完成授权。";
  129. }, token);
  130. PlayerName = _account.Name;
  131. AccountStatus = "正版账号 · " + _account.Name;
  132. Status = "微软登录成功,本次会话可直接启动游戏。";
  133. }
  134. finally { ShowLoginCode = false; }
  135. });
  136. [RelayCommand(CanExecute = nameof(CanWork))]
  137. private void Logout()
  138. {
  139. _account = null;
  140. AccountStatus = "离线账号 · 可登录微软正版账号";
  141. Status = "已退出账号。";
  142. }
  143. [RelayCommand]
  144. private void Cancel() => _operation?.Cancel();
  145. [RelayCommand(CanExecute = nameof(CanWork))]
  146. private void SaveSettings()
  147. {
  148. try { ApplySettings(); LoadInstalledVersions(); Status = "设置已保存。"; }
  149. catch (Exception ex) { Status = ex.Message; }
  150. }
  151. private void ApplySettings()
  152. {
  153. if (IsDesktop)
  154. {
  155. if (!Path.IsPathFullyQualified(GameRoot)) throw new ArgumentException("游戏目录需要填写绝对路径。");
  156. _paths = new LauncherPaths(GameRoot.Trim());
  157. }
  158. Directory.CreateDirectory(_paths.Root);
  159. MemoryMb = Math.Clamp(MemoryMb, 512, 32768);
  160. var settings = new JsonObject
  161. {
  162. ["gameRoot"] = _paths.Root, ["javaPath"] = JavaPath, ["memoryMb"] = (int)MemoryMb,
  163. ["theme"] = SelectedTheme.Name, ["playerName"] = _account is null ? PlayerName : "Player"
  164. };
  165. Directory.CreateDirectory(Path.GetDirectoryName(_settingsPath)!);
  166. var temporary = _settingsPath + ".tmp";
  167. File.WriteAllText(temporary, settings.ToJsonString());
  168. File.Move(temporary, _settingsPath, true);
  169. }
  170. private void LoadSettings()
  171. {
  172. if (!File.Exists(_settingsPath)) return;
  173. try
  174. {
  175. var settings = JsonNode.Parse(File.ReadAllText(_settingsPath))!;
  176. var root = settings["gameRoot"]?.GetValue<string>();
  177. if (IsDesktop && root is not null && Path.IsPathFullyQualified(root)) { _paths = new(root); GameRoot = root; }
  178. JavaPath = settings["javaPath"]?.GetValue<string>() ?? "";
  179. MemoryMb = Math.Clamp(settings["memoryMb"]?.GetValue<int>() ?? 2048, 512, 32768);
  180. PlayerName = settings["playerName"]?.GetValue<string>() ?? "Player";
  181. SelectedTheme = Themes.FirstOrDefault(t => t.Name == settings["theme"]?.GetValue<string>()) ?? Themes[0];
  182. }
  183. catch (Exception ex) when (ex is IOException or System.Text.Json.JsonException or InvalidOperationException)
  184. { Status = "设置文件无法读取,已使用默认设置。"; }
  185. }
  186. private void LoadInstalledVersions()
  187. {
  188. Versions.Clear();
  189. SelectedVersion = null;
  190. if (!Directory.Exists(_paths.Versions)) return;
  191. foreach (var directory in Directory.EnumerateDirectories(_paths.Versions).OrderDescending())
  192. {
  193. var id = Path.GetFileName(directory);
  194. if (File.Exists(Path.Combine(directory, id + ".json")) && File.Exists(Path.Combine(directory, id + ".jar")))
  195. Versions.Add(new(id, "installed", "", ""));
  196. }
  197. SelectedVersion = Versions.FirstOrDefault();
  198. }
  199. private async Task RunAsync(Func<CancellationToken, Task> operation)
  200. {
  201. if (IsBusy) return;
  202. IsBusy = true;
  203. Progress = 0;
  204. using var cancellation = new CancellationTokenSource();
  205. _operation = cancellation;
  206. try { await operation(cancellation.Token); }
  207. catch (OperationCanceledException) { Status = "操作已取消,可重新开始。"; }
  208. catch (Exception ex) { Status = ex.Message; }
  209. finally { _operation = null; IsBusy = false; }
  210. }
  211. }