using IPA.Utilities; using IPA.Loader; using Ionic.Zip; using Newtonsoft.Json; using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using UnityEngine; using UnityEngine.Networking; using Logger = IPA.Logging.Logger; using Version = SemVer.Version; using IPA.Updating.Backup; namespace IPA.Updating.ModsaberML { class Updater : MonoBehaviour { public static Updater instance; public void Awake() { try { if (instance != null) Destroy(this); else { instance = this; CheckForUpdates(); } } catch (Exception e) { Logger.updater.Error(e); } } public void CheckForUpdates() { StartCoroutine(CheckForUpdatesCoroutine()); } private class ParsedPluginMeta : PluginManager.BSPluginMeta { private Version _verCache = null; public Version ModVersion { get { if (_verCache == null) _verCache = new Version(ModsaberInfo.CurrentVersion); return _verCache; } } public ParsedPluginMeta(PluginManager.BSPluginMeta meta) { this.Plugin = meta.Plugin; this.ModsaberInfo = meta.ModsaberInfo; this.Filename = meta.Filename; } } private struct UpdateStruct { public ParsedPluginMeta plugin; public ApiEndpoint.Mod externInfo; } IEnumerator CheckForUpdatesCoroutine() { Logger.updater.Info("Checking for mod updates..."); var toUpdate = new List(); var GameVersion = new Version(Application.version); foreach (var _plugin in PluginManager.BSMetas) { var plugin = new ParsedPluginMeta(_plugin); var info = plugin.ModsaberInfo; if (info == null) continue; using (var request = UnityWebRequest.Get(ApiEndpoint.ApiBase + string.Format(ApiEndpoint.GetApprovedEndpoint, info.InternalName))) { yield return request.SendWebRequest(); if (request.isNetworkError) { Logger.updater.Error("Network error while trying to update mods"); Logger.updater.Error(request.error); continue; } if (request.isHttpError) { if (request.responseCode == 404) { Logger.updater.Error($"Mod {plugin.Plugin.Name} not found under name {info.InternalName}"); continue; } Logger.updater.Error($"Server returned an error code while trying to update mod {plugin.Plugin.Name}"); Logger.updater.Error(request.error); continue; } var json = request.downloadHandler.text; ApiEndpoint.Mod modRegistry; try { modRegistry = JsonConvert.DeserializeObject(json); Logger.updater.Debug(modRegistry.ToString()); } catch (Exception e) { Logger.updater.Error($"Parse error while trying to update mods"); Logger.updater.Error(e); continue; } Logger.updater.Debug($"Found Modsaber.ML registration for {plugin.Plugin.Name} ({info.InternalName})"); Logger.updater.Debug($"Installed version: {plugin.ModVersion}; Latest version: {modRegistry.Version}"); if (modRegistry.Version > plugin.ModVersion) { Logger.updater.Debug($"{plugin.Plugin.Name} needs an update!"); if (modRegistry.GameVersion == GameVersion) { Logger.updater.Debug($"Queueing update..."); toUpdate.Add(new UpdateStruct { plugin = plugin, externInfo = modRegistry }); } else { Logger.updater.Warn($"Update avaliable for {plugin.Plugin.Name}, but for a different Beat Saber version!"); } } } } Logger.updater.Info($"{toUpdate.Count} mods need updating"); if (toUpdate.Count == 0) yield break; string tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + Path.GetRandomFileName()); Directory.CreateDirectory(tempDirectory); foreach (var item in toUpdate) { StartCoroutine(UpdateModCoroutine(item, tempDirectory)); } } class StreamDownloadHandler : DownloadHandlerScript { public MemoryStream Stream { get; set; } public StreamDownloadHandler(MemoryStream stream) : base() { Stream = stream; } protected override void ReceiveContentLength(int contentLength) { Stream.Capacity = contentLength; Logger.updater.Debug($"Got content length: {contentLength}"); } protected override void CompleteContent() { Logger.updater.Debug("Download complete"); } protected override bool ReceiveData(byte[] data, int dataLength) { if (data == null || data.Length < 1) { Logger.updater.Debug("CustomWebRequest :: ReceiveData - received a null/empty buffer"); return false; } Stream.Write(data, 0, dataLength); return true; } protected override byte[] GetData() { return null; } protected override float GetProgress() { return 0f; } public override string ToString() { return $"{base.ToString()} ({Stream?.ToString()})"; } } private void ExtractPluginAsync(MemoryStream stream, UpdateStruct item, ApiEndpoint.Mod.PlatformFile fileInfo, string tempDirectory) { Logger.updater.Debug($"Extracting ZIP file for {item.plugin.Plugin.Name}"); var data = stream.GetBuffer(); SHA1 sha = new SHA1CryptoServiceProvider(); var hash = sha.ComputeHash(data); if (!LoneFunctions.UnsafeCompare(hash, fileInfo.Hash)) throw new Exception("The hash for the file doesn't match what is defined"); var newFiles = new List(); var backup = new BackupUnit(tempDirectory, $"backup-{item.plugin.ModsaberInfo.InternalName}"); try { bool shouldDeleteOldFile = true; using (var zipFile = ZipFile.Read(stream)) { Logger.updater.Debug("Streams opened"); foreach (var entry in zipFile) { if (entry.IsDirectory) { Logger.updater.Debug($"Creating directory {entry.FileName}"); Directory.CreateDirectory(Path.Combine(Environment.CurrentDirectory, entry.FileName)); } else { using (var ostream = new MemoryStream((int)entry.UncompressedSize)) { entry.Extract(ostream); ostream.Seek(0, SeekOrigin.Begin); sha = new SHA1CryptoServiceProvider(); var fileHash = sha.ComputeHash(ostream); if (!LoneFunctions.UnsafeCompare(fileHash, fileInfo.FileHashes[entry.FileName])) throw new Exception("The hash for the file doesn't match what is defined"); ostream.Seek(0, SeekOrigin.Begin); FileInfo targetFile = new FileInfo(Path.Combine(Environment.CurrentDirectory, entry.FileName)); Directory.CreateDirectory(targetFile.DirectoryName); if (targetFile.FullName == item.plugin.Filename) shouldDeleteOldFile = false; // overwriting old file, no need to delete if (targetFile.Exists) backup.Add(targetFile); else newFiles.Add(targetFile); Logger.updater.Debug($"Extracting file {targetFile.FullName}"); var fstream = targetFile.Create(); ostream.CopyTo(fstream); } } } } if (item.plugin.Plugin is SelfPlugin) { // currently updating self Process.Start(new ProcessStartInfo { FileName = item.plugin.Filename, Arguments = $"--waitfor={Process.GetCurrentProcess().Id} --nowait", UseShellExecute = false }); } else if (shouldDeleteOldFile) File.Delete(item.plugin.Filename); } catch (Exception) { // something failed; restore foreach (var file in newFiles) file.Delete(); backup.Restore(); backup.Delete(); throw; } backup.Delete(); Logger.updater.Debug("Downloader exited"); } IEnumerator UpdateModCoroutine(UpdateStruct item, string tempDirectory) { Logger.updater.Debug($"Steam avaliable: {SteamCheck.IsAvailable}"); ApiEndpoint.Mod.PlatformFile platformFile; if (SteamCheck.IsAvailable || item.externInfo.Files.Oculus == null) platformFile = item.externInfo.Files.Steam; else platformFile = item.externInfo.Files.Oculus; string url = platformFile.DownloadPath; Logger.updater.Debug($"URL = {url}"); const int MaxTries = 3; int maxTries = MaxTries; while (maxTries > 0) { if (maxTries-- != MaxTries) Logger.updater.Info($"Re-trying download..."); using (var stream = new MemoryStream()) using (var request = UnityWebRequest.Get(url)) using (var taskTokenSource = new CancellationTokenSource()) { var dlh = new StreamDownloadHandler(stream); request.downloadHandler = dlh; Logger.updater.Debug("Sending request"); //Logger.updater.Debug(request?.downloadHandler?.ToString() ?? "DLH==NULL"); yield return request.SendWebRequest(); Logger.updater.Debug("Download finished"); if (request.isNetworkError) { Logger.updater.Error("Network error while trying to update mod"); Logger.updater.Error(request.error); taskTokenSource.Cancel(); continue; } if (request.isHttpError) { Logger.updater.Error($"Server returned an error code while trying to update mod"); Logger.updater.Error(request.error); taskTokenSource.Cancel(); continue; } stream.Seek(0, SeekOrigin.Begin); // reset to beginning var downloadTask = Task.Run(() => { // use slightly more multithreaded approach than coroutines ExtractPluginAsync(stream, item, platformFile, tempDirectory); }, taskTokenSource.Token); while (!(downloadTask.IsCompleted || downloadTask.IsCanceled || downloadTask.IsFaulted)) yield return null; // pause coroutine until task is done if (downloadTask.IsFaulted) { Logger.updater.Error($"Error downloading mod {item.plugin.Plugin.Name}"); Logger.updater.Error(downloadTask.Exception); continue; } break; } } if (maxTries == 0) Logger.updater.Warn($"Plugin download failed {MaxTries} times, not re-trying"); else Logger.updater.Debug("Download complete"); } } }