// // MaaWpfGui - A part of the MaaCoreArknights project // Copyright (C) 2021 MistEO and Contributors // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY // using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net; using System.Runtime.InteropServices; using System.Text.RegularExpressions; using System.Windows; using System.Windows.Documents; using MaaWpfGui.Constants; using MaaWpfGui.Helper; using MaaWpfGui.Services; using MaaWpfGui.Utilities; using Markdig; using Neo.Markdig.Xaml; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Stylet; using StyletIoC; namespace MaaWpfGui.ViewModels.UI { /// /// The view model of version update. /// public class VersionUpdateViewModel : Screen { private readonly SettingsViewModel _settingsViewModel; private readonly TaskQueueViewModel _taskQueueViewModel; private readonly IHttpService _httpService; /// /// Initializes a new instance of the class. /// /// The IoC container. public VersionUpdateViewModel(IContainer container) { _settingsViewModel = container.Get(); _taskQueueViewModel = container.Get(); _httpService = container.Get(); } [DllImport("MaaCore.dll")] private static extern IntPtr AsstGetVersion(); private static string AddContributorLink(string text) { /* // "@ " -> "@ " // "`@`" -> "`@`" // "@MistEO" -> "[@MistEO](https://github.com/MistEO)" // "[@MistEO]" -> "[@MistEO]" */ return Regex.Replace(text, @"([^\[`]|^)@([^\s]+)", "$1[@$2](https://github.com/$2)"); } private readonly string _curVersion = Marshal.PtrToStringAnsi(AsstGetVersion()); private string _latestVersion; private string _updateTag = ConfigurationHelper.GetValue(ConfigurationKeys.VersionName, string.Empty); /// /// Gets or sets the update tag. /// public string UpdateTag { get => _updateTag; set { SetAndNotify(ref _updateTag, value); ConfigurationHelper.SetValue(ConfigurationKeys.VersionName, value); } } private string _updateInfo = ConfigurationHelper.GetValue(ConfigurationKeys.VersionUpdateBody, string.Empty); // private static readonly MarkdownPipeline s_markdownPipeline = new MarkdownPipelineBuilder().UseXamlSupportedExtensions().Build(); /// /// Gets or sets the update info. /// public string UpdateInfo { get { try { return AddContributorLink(_updateInfo); } catch { return _updateInfo; } } set { SetAndNotify(ref _updateInfo, value); ConfigurationHelper.SetValue(ConfigurationKeys.VersionUpdateBody, value); } } public FlowDocument UpdateInfoDoc => MarkdownXaml.ToFlowDocument(UpdateInfo, new MarkdownPipelineBuilder().UseXamlSupportedExtensions().Build()); private string _updateUrl; /// /// Gets or sets the update URL. /// public string UpdateUrl { get => _updateUrl; set => SetAndNotify(ref _updateUrl, value); } private bool _isFirstBootAfterUpdate = Convert.ToBoolean(ConfigurationHelper.GetValue(ConfigurationKeys.VersionUpdateIsFirstBoot, bool.FalseString)); /// /// Gets or sets a value indicating whether it is the first boot after updating. /// public bool IsFirstBootAfterUpdate { get => _isFirstBootAfterUpdate; set { SetAndNotify(ref _isFirstBootAfterUpdate, value); ConfigurationHelper.SetValue(ConfigurationKeys.VersionUpdateIsFirstBoot, value.ToString()); } } private string _updatePackageName = ConfigurationHelper.GetValue(ConfigurationKeys.VersionUpdatePackage, string.Empty); /// /// Gets or sets the name of the update package. /// public string UpdatePackageName { get => _updatePackageName; set { SetAndNotify(ref _updatePackageName, value); ConfigurationHelper.SetValue(ConfigurationKeys.VersionUpdatePackage, value); } } /// /// Gets the OS architecture. /// public static string OSArchitecture => RuntimeInformation.OSArchitecture.ToString().ToLower(); /// /// Gets a value indicating whether the OS is arm. /// public static bool IsArm => OSArchitecture.StartsWith("arm"); private const string RequestUrl = "repos/MaaAssistantArknights/MaaRelease/releases"; private const string StableRequestUrl = "repos/MaaAssistantArknights/MaaAssistantArknights/releases/latest"; private const string MaaReleaseRequestUrlByTag = "repos/MaaAssistantArknights/MaaRelease/releases/tags/"; private const string InfoRequestUrl = "repos/MaaAssistantArknights/MaaAssistantArknights/releases/tags/"; private JObject _latestJson; private JObject _assetsObject; /// /// 检查是否有已下载的更新包,如果有立即更新并重启进程。 /// /// 操作成功返回 ,反之则返回 public bool CheckAndUpdateNow() { if (UpdateTag == string.Empty || UpdatePackageName == string.Empty || !File.Exists(UpdatePackageName)) { return false; } Execute.OnUIThread(() => { using var toast = new ToastNotification(LocalizationHelper.GetString("NewVersionZipFileFoundTitle")); toast.AppendContentText(LocalizationHelper.GetString("NewVersionZipFileFoundDescDecompressing")) .AppendContentText(UpdateTag) .ShowUpdateVersion(row: 2); }); string curDir = Directory.GetCurrentDirectory(); string extractDir = Path.Combine(curDir, "NewVersionExtract"); string oldFileDir = Path.Combine(curDir, ".old"); // 解压 try { if (Directory.Exists(extractDir)) { Directory.Delete(extractDir, true); } System.IO.Compression.ZipFile.ExtractToDirectory(UpdatePackageName, extractDir); } catch (InvalidDataException) { File.Delete(UpdatePackageName); Execute.OnUIThread(() => { using var toast = new ToastNotification(LocalizationHelper.GetString("NewVersionZipFileBrokenTitle")); toast.AppendContentText(LocalizationHelper.GetString("NewVersionZipFileBrokenDescFilename") + UpdatePackageName) .AppendContentText(LocalizationHelper.GetString("NewVersionZipFileBrokenDescDeleted")) .ShowUpdateVersion(); }); return false; } string removeListFile = Path.Combine(extractDir, "removelist.txt"); if (File.Exists(removeListFile)) { string[] removeList = File.ReadAllLines(removeListFile); foreach (string file in removeList) { string path = Path.Combine(curDir, file); if (File.Exists(path)) { string moveTo = Path.Combine(oldFileDir, file); if (File.Exists(moveTo)) { File.Delete(moveTo); } else { Directory.CreateDirectory(Path.GetDirectoryName(moveTo)); } File.Move(path, moveTo); } } } Directory.CreateDirectory(oldFileDir); foreach (var dir in Directory.GetDirectories(extractDir, "*", SearchOption.AllDirectories)) { Directory.CreateDirectory(dir.Replace(extractDir, curDir)); Directory.CreateDirectory(dir.Replace(extractDir, oldFileDir)); } // 复制新版本的所有文件到当前路径下 foreach (var file in Directory.GetFiles(extractDir, "*", SearchOption.AllDirectories)) { var fileName = Path.GetFileName(file); if (fileName == "removelist.txt" || fileName == "md5sum.txt") { continue; } string curFileName = file.Replace(extractDir, curDir); if (File.Exists(curFileName)) { string moveTo = file.Replace(extractDir, oldFileDir); if (File.Exists(moveTo)) { File.Delete(moveTo); } File.Move(curFileName, moveTo); } File.Move(file, curFileName); } foreach (var oldFile in Directory.GetFiles(curDir, "*.old")) { File.Delete(oldFile); } // 操作完了,把解压的文件删了 Directory.Delete(extractDir, true); File.Delete(UpdatePackageName); // 保存更新信息,下次启动后会弹出已更新完成的提示 UpdatePackageName = string.Empty; IsFirstBootAfterUpdate = true; ConfigurationHelper.Release(); // 重启进程(启动的是更新后的程序了) var newProcess = new Process(); newProcess.StartInfo.FileName = AppDomain.CurrentDomain.FriendlyName; newProcess.StartInfo.WorkingDirectory = Directory.GetCurrentDirectory(); newProcess.Start(); Application.Current.Shutdown(); return true; } public enum CheckUpdateRetT { /// /// 操作成功 /// OK, /// /// 未知错误 /// UnknownError, /// /// 无需更新 /// NoNeedToUpdate, /// /// 已经是最新版 /// AlreadyLatest, /// /// 网络错误 /// NetworkError, /// /// 获取信息失败 /// FailedToGetInfo, /// /// 新版正在构建中 /// NewVersionIsBeingBuilt, } public enum Downloader { /// /// 原生下载器 /// Native, } /// /// 检查更新,并下载更新包。 /// /// 是否强制检查。 /// 操作成功返回 ,反之则返回 public CheckUpdateRetT CheckAndDownloadUpdate(bool force = false) { _settingsViewModel.IsCheckingForUpdates = true; var checkResult = ((Func)(() => { // 检查更新 var checkRet = CheckUpdate(force); if (checkRet != CheckUpdateRetT.OK) { return checkRet; } // 保存新版本的信息 var name = _latestJson["name"]?.ToString(); UpdateTag = name == string.Empty ? _latestJson["tag_name"]?.ToString() : name; var body = _latestJson["body"]?.ToString(); if (body == string.Empty) { string ComparableHash(string version) { if (isStdVersion(version)) { return version; } else if (Semver.SemVersion.TryParse(version, Semver.SemVersionStyles.AllowLowerV, out var semVersion) && isNightlyVersion(semVersion)) { // v4.6.6-1.g{Hash} // v4.6.7-beta.2.8.g{Hash} var commitHash = semVersion.PrereleaseIdentifiers.Last().ToString(); if (commitHash.StartsWith("g")) { commitHash = commitHash.Remove(0, 1); } return commitHash; } return null; } var curHash = ComparableHash(_curVersion); var latestHash = ComparableHash(_latestVersion); if (curHash != null && latestHash != null) { body = $"**Full Changelog**: [{curHash} -> {latestHash}](https://github.com/MaaAssistantArknights/MaaAssistantArknights/compare/{curHash}...{latestHash})"; } } UpdateInfo = body; UpdateUrl = _latestJson["html_url"]?.ToString(); bool otaFound = _assetsObject != null; bool goDownload = otaFound && _settingsViewModel.AutoDownloadUpdatePackage; #pragma warning disable IDE0042 var openUrlToastButton = ( text: LocalizationHelper.GetString("NewVersionFoundButtonGoWebpage"), action: new Action(() => { if (!string.IsNullOrWhiteSpace(UpdateUrl)) { Process.Start(UpdateUrl); } })); #pragma warning restore IDE0042 Execute.OnUIThread(() => { using var toast = new ToastNotification(otaFound ? LocalizationHelper.GetString("NewVersionFoundTitle") : LocalizationHelper.GetString("NewVersionFoundButNoPackageTitle")); if (goDownload) { OutputDownloadProgress(downloading: false, output: LocalizationHelper.GetString("NewVersionDownloadPreparing")); toast.AppendContentText(LocalizationHelper.GetString("NewVersionFoundDescDownloading")); } toast.AppendContentText(LocalizationHelper.GetString("NewVersionFoundDescId") + UpdateTag); if (!otaFound) { toast.AppendContentText(LocalizationHelper.GetString("NewVersionFoundButNoPackageDesc")); } var toastDesc = UpdateInfo.Length > 100 ? UpdateInfo.Substring(0, 100) + "..." : UpdateInfo; toast.AppendContentText(LocalizationHelper.GetString("NewVersionFoundDescInfo") + toastDesc); toast.AddButtonLeft(openUrlToastButton.text, openUrlToastButton.action); toast.ButtonSystemUrl = UpdateUrl; toast.ShowUpdateVersion(); }); UpdatePackageName = _assetsObject?["name"]?.ToString() ?? string.Empty; if (!goDownload || string.IsNullOrWhiteSpace(UpdatePackageName)) { OutputDownloadProgress(string.Empty); return CheckUpdateRetT.NoNeedToUpdate; } // 下载压缩包 var downloaded = false; var mirroredReplaceMap = new List> { new Tuple("github.com", "agent.imgg.dev"), new Tuple("https://", "https://git.114514.pro/https://"), new Tuple("https://", "https://ghproxy.com/https://"), new Tuple("github.com", "ota.maa.plus"), new Tuple("github.com", "download.fastgit.org"), null, }; string rawUrl = _assetsObject["browser_download_url"]?.ToString(); const int DownloadRetryMaxTimes = 1; for (int i = 0; i <= DownloadRetryMaxTimes && !downloaded; i++) { var url = rawUrl; foreach (var repTuple in mirroredReplaceMap) { if (repTuple != null) { url = url.Replace(repTuple.Item1, repTuple.Item2); } if (DownloadGithubAssets(url, _assetsObject)) { OutputDownloadProgress(downloading: false, output: LocalizationHelper.GetString("NewVersionDownloadCompletedTitle")); downloaded = true; break; } } } if (!downloaded) { OutputDownloadProgress(downloading: false, output: LocalizationHelper.GetString("NewVersionDownloadFailedTitle")); Execute.OnUIThread(() => { using var toast = new ToastNotification(LocalizationHelper.GetString("NewVersionDownloadFailedTitle")); toast.ButtonSystemUrl = UpdateUrl; toast.AppendContentText(LocalizationHelper.GetString("NewVersionDownloadFailedDesc")) .AddButtonLeft(openUrlToastButton.text, openUrlToastButton.action) .Show(); }); return CheckUpdateRetT.NoNeedToUpdate; } return CheckUpdateRetT.OK; }))(); _settingsViewModel.IsCheckingForUpdates = false; return checkResult; } public void AskToRestart() { MessageBoxHelper.Unregister(); MessageBoxHelper.Yes = LocalizationHelper.GetString("Ok"); MessageBoxHelper.No = LocalizationHelper.GetString("ManualRestart"); MessageBoxHelper.Register(); var result = MessageBox.Show( LocalizationHelper.GetString("NewVersionDownloadCompletedDesc"), LocalizationHelper.GetString("NewVersionDownloadCompletedTitle"), MessageBoxButton.YesNo, MessageBoxImage.Question); MessageBoxHelper.Unregister(); if (result == MessageBoxResult.Yes) { Application.Current.Shutdown(); System.Windows.Forms.Application.Restart(); } } /// /// 检查更新。 /// /// 是否强制检查。 /// 检查到更新返回 ,反之则返回 private CheckUpdateRetT CheckUpdate(bool force = false) { // 自动更新或者手动触发 if (!(_settingsViewModel.UpdateCheck || force)) { return CheckUpdateRetT.NoNeedToUpdate; } // 调试版不检查更新 if (isDebugVersion()) { return CheckUpdateRetT.FailedToGetInfo; } const int RequestRetryMaxTimes = 2; try { if (!_settingsViewModel.UpdateBeta && !_settingsViewModel.UpdateNightly) { // 稳定版更新使用主仓库 /latest 接口 // 直接使用 MaaRelease 的话,30 个可能会找不到稳定版,因为有可能 Nightly 发了很多 var stableResponse = RequestGithubApi(StableRequestUrl, RequestRetryMaxTimes); if (string.IsNullOrEmpty(stableResponse)) { return CheckUpdateRetT.NetworkError; } _latestJson = JsonConvert.DeserializeObject(stableResponse) as JObject; _latestVersion = _latestJson["tag_name"].ToString(); stableResponse = RequestGithubApi(MaaReleaseRequestUrlByTag + _latestVersion, RequestRetryMaxTimes); // 主仓库能找到版,但是 MaaRelease 找不到,说明 MaaRelease 还没有同步(一般过个十分钟就同步好了) if (string.IsNullOrEmpty(stableResponse)) { return CheckUpdateRetT.NewVersionIsBeingBuilt; } _latestJson = JsonConvert.DeserializeObject(stableResponse) as JObject; } else { // 非稳定版更新使用 MaaRelease/releases 接口 var response = RequestGithubApi(RequestUrl, RequestRetryMaxTimes); if (string.IsNullOrEmpty(response)) { return CheckUpdateRetT.NetworkError; } var releaseArray = JsonConvert.DeserializeObject(response) as JArray; _latestJson = null; foreach (var item in releaseArray) { if (!_settingsViewModel.UpdateNightly && !isStdVersion(item["tag_name"].ToString())) { continue; } _latestJson = item as JObject; break; } } if (_latestJson == null) { return CheckUpdateRetT.AlreadyLatest; } _latestVersion = _latestJson["tag_name"].ToString(); var releaseAssets = _latestJson["assets"] as JArray; if (_settingsViewModel.UpdateNightly) { if (_curVersion == _latestVersion) { return CheckUpdateRetT.AlreadyLatest; } } else { bool curParsed = Semver.SemVersion.TryParse(_curVersion, Semver.SemVersionStyles.AllowLowerV, out var curVersionObj); bool latestPared = Semver.SemVersion.TryParse(_latestVersion, Semver.SemVersionStyles.AllowLowerV, out var latestVersionObj); if (curParsed && latestPared) { if (curVersionObj.CompareSortOrderTo(latestVersionObj) >= 0) { return CheckUpdateRetT.AlreadyLatest; } } else if (string.CompareOrdinal(_curVersion, _latestVersion) >= 0) { return CheckUpdateRetT.AlreadyLatest; } } // 从主仓库获取changelog等信息 // 非稳定版本是 Nightly 下载的,主仓库没有它的更新信息,不必请求 if (isStdVersion(_latestVersion)) { var infoResponse = RequestGithubApi(InfoRequestUrl + _latestVersion, RequestRetryMaxTimes); if (string.IsNullOrEmpty(infoResponse)) { return CheckUpdateRetT.FailedToGetInfo; } _latestJson = JsonConvert.DeserializeObject(infoResponse) as JObject; } _assetsObject = null; foreach (var curAssets in releaseAssets) { string name = curAssets["name"].ToString().ToLower(); if (name.Contains("ota") && name.Contains("win") && name.Contains($"{_curVersion}_{_latestVersion}")) { _assetsObject = curAssets as JObject; if (IsArm ^ name.Contains("arm")) { continue; // 兼容旧版本,以前 ota 不区分指令集架构 } else { break; } } } return CheckUpdateRetT.OK; } catch (Exception e) { // Refactor pending return CheckUpdateRetT.UnknownError; } } private string RequestGithubApi(string url, int retryTimes) { string response = string.Empty; string[] requestSource = { "https://api.github.com/", "https://api.kgithub.com/" }; do { for (var i = 0; i < requestSource.Length; i++) { response = _httpService.GetStringAsync(new Uri(requestSource[i] + url)).ConfigureAwait(false).GetAwaiter().GetResult(); if (!string.IsNullOrEmpty(response)) { break; } } } while (string.IsNullOrEmpty(response) && retryTimes-- > 0); return response; } /// /// 获取 GitHub Assets 对象对应的文件 /// /// 下载链接 /// Github Assets 对象 /// 操作成功返回 true,反之则返回 false private bool DownloadGithubAssets(string url, JObject assetsObject) { _logItemViewModels = _taskQueueViewModel.LogItemViewModels; return _httpService.DownloadFileAsync( new Uri(url), assetsObject["name"].ToString(), assetsObject["content_type"].ToString()) .ConfigureAwait(false).GetAwaiter().GetResult(); } private static System.Collections.ObjectModel.ObservableCollection _logItemViewModels = null; public static void OutputDownloadProgress(long value = 0, long maximum = 1, int len = 0, double ts = 1) { OutputDownloadProgress( string.Format("[{0:F}MiB/{1:F}MiB({2}%) {3:F} KiB/s]", value / 1048576.0, maximum / 1048576.0, 100 * value / maximum, len / ts / 1024.0)); } private static void OutputDownloadProgress(string output, bool downloading = true) { if (_logItemViewModels == null) { return; } var log = new LogItemViewModel(downloading ? LocalizationHelper.GetString("NewVersionFoundDescDownloading") + "\n" + output : output, UiLogColor.Download); Application.Current.Dispatcher.Invoke(() => { if (_logItemViewModels.Count > 0 && _logItemViewModels[0].Color == UiLogColor.Download) { if (!string.IsNullOrEmpty(output)) { _logItemViewModels[0] = log; } else { _logItemViewModels.RemoveAt(0); } } else if (!string.IsNullOrEmpty(output)) { _logItemViewModels.Clear(); _logItemViewModels.Add(log); } }); } private bool isDebugVersion(string version = null) { version ??= _curVersion; return version == "DEBUG VERSION"; } private bool isStdVersion(string version = null) { // 正式版:vX.X.X // DevBuild (CI):yyyy-MM-dd-HH-mm-ss-{CommitHash[..7]} // DevBuild (Local):yyyy-MM-dd-HH-mm-ss-{CommitHash[..7]}-Local // Release (Local Commit):v.{CommitHash[..7]}-Local // Release (Local Tag):{Tag}-Local // Debug (Local):DEBUG VERSION // Script Compiled:c{CommitHash[..7]} version ??= _curVersion; if (isDebugVersion(version)) { return false; } else if (version.StartsWith("c") || version.StartsWith("20") || version.Contains("Local")) { return false; } else if (!Semver.SemVersion.TryParse(version, Semver.SemVersionStyles.AllowLowerV, out var semVersion)) { return false; } else if (isNightlyVersion(semVersion)) { return false; } return true; } private bool isNightlyVersion(Semver.SemVersion version) { if (!version.IsPrerelease) { return false; } // v{Major}.{Minor}.{Patch}-{Prerelease}.{CommitDistance}.g{CommitHash} // v4.6.7-beta.2.1.g1234567 // v4.6.8-5.g1234567 var lastId = version.PrereleaseIdentifiers.LastOrDefault().ToString(); return lastId.StartsWith("g") && lastId.Length >= 7; } /// /// 复制文件夹内容并覆盖已存在的相同名字的文件 /// /// 源文件夹 /// 目标文件夹 public static void CopyFilesRecursively(string sourcePath, string targetPath) { Directory.CreateDirectory(targetPath); // Now Create all of the directories foreach (string dirPath in Directory.GetDirectories(sourcePath, "*", SearchOption.AllDirectories)) { Directory.CreateDirectory(dirPath.Replace(sourcePath, targetPath)); } // Copy all the files & Replaces any files with the same name foreach (string newPath in Directory.GetFiles(sourcePath, "*.*", SearchOption.AllDirectories)) { File.Copy(newPath, newPath.Replace(sourcePath, targetPath), true); } } /// /// The event handler of opening hyperlink. /// /// The sender. /// The event arguments. public void OpenHyperlink(object sender, System.Windows.Input.ExecutedRoutedEventArgs e) { Process.Start(e.Parameter.ToString()); } } }