You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

615 lines
24 KiB

6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
  1. using IPA.Utilities;
  2. using IPA.Loader;
  3. using Ionic.Zip;
  4. using Newtonsoft.Json;
  5. using System;
  6. using System.Collections;
  7. using System.Collections.Generic;
  8. using System.Diagnostics;
  9. using System.IO;
  10. using System.Linq;
  11. using System.Security.Cryptography;
  12. using System.Text;
  13. using System.Text.RegularExpressions;
  14. using System.Threading;
  15. using System.Threading.Tasks;
  16. using UnityEngine;
  17. using UnityEngine.Networking;
  18. using SemVer;
  19. using Logger = IPA.Logging.Logger;
  20. using Version = SemVer.Version;
  21. using IPA.Updating.Backup;
  22. using System.Runtime.Serialization;
  23. using System.Reflection;
  24. using static IPA.Loader.PluginManager;
  25. namespace IPA.Updating.ModsaberML
  26. {
  27. class Updater : MonoBehaviour
  28. {
  29. public static Updater instance;
  30. public void Awake()
  31. {
  32. try
  33. {
  34. if (instance != null)
  35. Destroy(this);
  36. else
  37. {
  38. instance = this;
  39. CheckForUpdates();
  40. }
  41. }
  42. catch (Exception e)
  43. {
  44. Logger.updater.Error(e);
  45. }
  46. }
  47. private void CheckForUpdates()
  48. {
  49. StartCoroutine(CheckForUpdatesCoroutine());
  50. }
  51. private class DependencyObject
  52. {
  53. public string Name { get; set; }
  54. public Version Version { get; set; } = null;
  55. public Version ResolvedVersion { get; set; } = null;
  56. public Range Requirement { get; set; } = null;
  57. public Range Conflicts { get; set; } = null;
  58. public bool Resolved { get; set; } = false;
  59. public bool Has { get; set; } = false;
  60. public HashSet<string> Consumers { get; set; } = new HashSet<string>();
  61. public bool MetaRequestFailed { get; set; } = false;
  62. public PluginInfo LocalPluginMeta { get; set; } = null;
  63. public override string ToString()
  64. {
  65. return $"{Name}@{Version}{(Resolved ? $" -> {ResolvedVersion}" : "")} - ({Requirement} ! {Conflicts}) {(Has ? $" Already have" : "")}";
  66. }
  67. }
  68. private Dictionary<string, string> requestCache = new Dictionary<string, string>();
  69. private IEnumerator GetModsaberEndpoint(string url, Ref<string> result)
  70. {
  71. if (requestCache.TryGetValue(url, out string value))
  72. {
  73. result.Value = value;
  74. yield break;
  75. }
  76. else
  77. {
  78. using (var request = UnityWebRequest.Get(ApiEndpoint.ApiBase + url))
  79. {
  80. yield return request.SendWebRequest();
  81. if (request.isNetworkError)
  82. {
  83. result.Error = new NetworkException($"Network error while trying to download: {request.error}");
  84. yield break;
  85. }
  86. if (request.isHttpError)
  87. {
  88. if (request.responseCode == 404)
  89. {
  90. result.Error = new NetworkException("Not found");
  91. yield break;
  92. }
  93. result.Error = new NetworkException($"Server returned error {request.error} while getting data");
  94. yield break;
  95. }
  96. result.Value = request.downloadHandler.text;
  97. requestCache[url] = result.Value;
  98. }
  99. }
  100. }
  101. private Dictionary<string, ApiEndpoint.Mod> modCache = new Dictionary<string, ApiEndpoint.Mod>();
  102. private IEnumerator GetModInfo(string name, string ver, Ref<ApiEndpoint.Mod> result)
  103. {
  104. var uri = string.Format(ApiEndpoint.GetModInfoEndpoint, Uri.EscapeUriString(name), Uri.EscapeUriString(ver));
  105. if (modCache.TryGetValue(uri, out ApiEndpoint.Mod value))
  106. {
  107. result.Value = value;
  108. yield break;
  109. }
  110. else
  111. {
  112. Ref<string> reqResult = new Ref<string>("");
  113. yield return GetModsaberEndpoint(uri, reqResult);
  114. try
  115. {
  116. result.Value = JsonConvert.DeserializeObject<ApiEndpoint.Mod>(reqResult.Value);
  117. modCache[uri] = result.Value;
  118. }
  119. catch (Exception e)
  120. {
  121. result.Error = new Exception("Error decoding response", e);
  122. yield break;
  123. }
  124. }
  125. }
  126. private Dictionary<string, List<ApiEndpoint.Mod>> modVersionsCache = new Dictionary<string, List<ApiEndpoint.Mod>>();
  127. private IEnumerator GetModVersionsMatching(string name, string range, Ref<List<ApiEndpoint.Mod>> result)
  128. {
  129. var uri = string.Format(ApiEndpoint.GetModsWithSemver, Uri.EscapeUriString(name), Uri.EscapeUriString(range));
  130. if (modVersionsCache.TryGetValue(uri, out List<ApiEndpoint.Mod> value))
  131. {
  132. result.Value = value;
  133. yield break;
  134. }
  135. else
  136. {
  137. Ref<string> reqResult = new Ref<string>("");
  138. yield return GetModsaberEndpoint(uri, reqResult);
  139. try
  140. {
  141. result.Value = JsonConvert.DeserializeObject<List<ApiEndpoint.Mod>>(reqResult.Value);
  142. modVersionsCache[uri] = result.Value;
  143. }
  144. catch (Exception e)
  145. {
  146. result.Error = new Exception("Error decoding response", e);
  147. yield break;
  148. }
  149. }
  150. }
  151. private IEnumerator CheckForUpdatesCoroutine()
  152. {
  153. var depList = new Ref<List<DependencyObject>>(new List<DependencyObject>());
  154. foreach (var plugin in BSMetas)
  155. { // initialize with data to resolve (1.1)
  156. if (plugin.ModsaberInfo != null)
  157. { // updatable
  158. var msinfo = plugin.ModsaberInfo;
  159. depList.Value.Add(new DependencyObject {
  160. Name = msinfo.InternalName,
  161. Version = new Version(msinfo.CurrentVersion),
  162. Requirement = new Range($">={msinfo.CurrentVersion}"),
  163. LocalPluginMeta = plugin
  164. });
  165. }
  166. }
  167. foreach (var dep in depList.Value)
  168. Logger.updater.Debug($"Phantom Dependency: {dep.ToString()}");
  169. yield return DependencyResolveFirstPass(depList);
  170. foreach (var dep in depList.Value)
  171. Logger.updater.Debug($"Dependency: {dep.ToString()}");
  172. yield return DependencyResolveSecondPass(depList);
  173. foreach (var dep in depList.Value)
  174. Logger.updater.Debug($"Dependency: {dep.ToString()}");
  175. DependendyResolveFinalPass(depList);
  176. }
  177. private IEnumerator DependencyResolveFirstPass(Ref<List<DependencyObject>> list)
  178. {
  179. for (int i = 0; i < list.Value.Count; i++)
  180. { // Grab dependencies (1.2)
  181. var dep = list.Value[i];
  182. var mod = new Ref<ApiEndpoint.Mod>(null);
  183. #region TEMPORARY get latest // SHOULD BE GREATEST OF VERSION // not going to happen because of disagreements with ModSaber
  184. yield return GetModInfo(dep.Name, "", mod);
  185. #endregion
  186. try { mod.Verify(); }
  187. catch (Exception e)
  188. {
  189. Logger.updater.Error($"Error getting info for {dep.Name}");
  190. Logger.updater.Error(e);
  191. dep.MetaRequestFailed = true;
  192. continue;
  193. }
  194. list.Value.AddRange(mod.Value.Dependencies.Select(d => new DependencyObject { Name = d.Name, Requirement = d.VersionRange, Consumers = new HashSet<string>() { dep.Name } }));
  195. list.Value.AddRange(mod.Value.Conflicts.Select(d => new DependencyObject { Name = d.Name, Conflicts = d.VersionRange, Consumers = new HashSet<string>() { dep.Name } }));
  196. }
  197. var depNames = new HashSet<string>();
  198. var final = new List<DependencyObject>();
  199. foreach (var dep in list.Value)
  200. { // agregate ranges and the like (1.3)
  201. if (!depNames.Contains(dep.Name))
  202. { // should add it
  203. depNames.Add(dep.Name);
  204. final.Add(dep);
  205. }
  206. else
  207. {
  208. var toMod = final.Where(d => d.Name == dep.Name).First();
  209. if (dep.Requirement != null)
  210. {
  211. toMod.Requirement = toMod.Requirement.Intersect(dep.Requirement);
  212. foreach (var consume in dep.Consumers)
  213. toMod.Consumers.Add(consume);
  214. }
  215. else if (dep.Conflicts != null)
  216. {
  217. if (toMod.Conflicts == null)
  218. toMod.Conflicts = dep.Conflicts;
  219. else
  220. toMod.Conflicts = new Range($"{toMod.Conflicts} || {dep.Conflicts}"); // there should be a better way to do this
  221. }
  222. }
  223. }
  224. list.Value = final;
  225. }
  226. private IEnumerator DependencyResolveSecondPass(Ref<List<DependencyObject>> list)
  227. {
  228. foreach(var dep in list.Value)
  229. {
  230. dep.Has = dep.Version != null; // dep.Version is only not null if its already installed
  231. if (dep.MetaRequestFailed)
  232. {
  233. Logger.updater.Warn($"{dep.Name} info request failed, not trying again");
  234. continue;
  235. }
  236. var modsMatching = new Ref<List<ApiEndpoint.Mod>>(null);
  237. yield return GetModVersionsMatching(dep.Name, dep.Requirement.ToString(), modsMatching);
  238. try { modsMatching.Verify(); }
  239. catch (Exception e)
  240. {
  241. Logger.updater.Error($"Error getting mod list for {dep.Name}");
  242. Logger.updater.Error(e);
  243. dep.MetaRequestFailed = true;
  244. continue;
  245. }
  246. var ver = modsMatching.Value.Where(nullCheck => nullCheck != null)
  247. .Where(versionCheck => versionCheck.GameVersion == BeatSaber.GameVersion && versionCheck.Approved)
  248. .Where(conflictsCheck => dep.Conflicts == null || !dep.Conflicts.IsSatisfied(conflictsCheck.Version))
  249. .Select(mod => mod.Version).Max(); // (2.1)
  250. if (dep.Resolved = ver != null) dep.ResolvedVersion = ver; // (2.2)
  251. dep.Has = dep.Version == dep.ResolvedVersion && dep.Resolved; // dep.Version is only not null if its already installed
  252. }
  253. }
  254. private void DependendyResolveFinalPass(Ref<List<DependencyObject>> list)
  255. { // also starts download of mods
  256. var toDl = new List<DependencyObject>();
  257. foreach (var dep in list.Value)
  258. { // figure out which ones need to be downloaded (3.1)
  259. if (dep.Resolved)
  260. {
  261. Logger.updater.Debug($"Resolved: {dep.ToString()}");
  262. if (!dep.Has)
  263. {
  264. Logger.updater.Debug($"To Download: {dep.ToString()}");
  265. toDl.Add(dep);
  266. }
  267. }
  268. else if (!dep.Has)
  269. {
  270. Logger.updater.Warn($"Could not resolve dependency {dep}");
  271. }
  272. }
  273. Logger.updater.Debug($"To Download {string.Join(", ", toDl.Select(d => $"{d.Name}@{d.ResolvedVersion}"))}");
  274. string tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + Path.GetRandomFileName());
  275. Directory.CreateDirectory(tempDirectory);
  276. Logger.updater.Debug($"Temp directory: {tempDirectory}");
  277. foreach (var item in toDl)
  278. StartCoroutine(UpdateModCoroutine(item, tempDirectory));
  279. }
  280. private IEnumerator UpdateModCoroutine(DependencyObject item, string tempDirectory)
  281. { // (3.2)
  282. Logger.updater.Debug($"Release: {BeatSaber.ReleaseType}");
  283. var mod = new Ref<ApiEndpoint.Mod>(null);
  284. yield return GetModInfo(item.Name, item.ResolvedVersion.ToString(), mod);
  285. try { mod.Verify(); }
  286. catch (Exception e)
  287. {
  288. Logger.updater.Error($"Error occurred while trying to get information for {item}");
  289. Logger.updater.Error(e);
  290. yield break;
  291. }
  292. ApiEndpoint.Mod.PlatformFile platformFile;
  293. if (BeatSaber.ReleaseType == BeatSaber.Release.Steam || mod.Value.Files.Oculus == null)
  294. platformFile = mod.Value.Files.Steam;
  295. else
  296. platformFile = mod.Value.Files.Oculus;
  297. string url = platformFile.DownloadPath;
  298. Logger.updater.Debug($"URL = {url}");
  299. const int MaxTries = 3;
  300. int maxTries = MaxTries;
  301. while (maxTries > 0)
  302. {
  303. if (maxTries-- != MaxTries)
  304. Logger.updater.Debug($"Re-trying download...");
  305. using (var stream = new MemoryStream())
  306. using (var request = UnityWebRequest.Get(url))
  307. using (var taskTokenSource = new CancellationTokenSource())
  308. {
  309. var dlh = new StreamDownloadHandler(stream);
  310. request.downloadHandler = dlh;
  311. Logger.updater.Debug("Sending request");
  312. //Logger.updater.Debug(request?.downloadHandler?.ToString() ?? "DLH==NULL");
  313. yield return request.SendWebRequest();
  314. Logger.updater.Debug("Download finished");
  315. if (request.isNetworkError)
  316. {
  317. Logger.updater.Error("Network error while trying to update mod");
  318. Logger.updater.Error(request.error);
  319. taskTokenSource.Cancel();
  320. continue;
  321. }
  322. if (request.isHttpError)
  323. {
  324. Logger.updater.Error($"Server returned an error code while trying to update mod");
  325. Logger.updater.Error(request.error);
  326. taskTokenSource.Cancel();
  327. continue;
  328. }
  329. stream.Seek(0, SeekOrigin.Begin); // reset to beginning
  330. var downloadTask = Task.Run(() =>
  331. { // use slightly more multithreaded approach than coroutines
  332. ExtractPluginAsync(stream, item, platformFile, tempDirectory);
  333. }, taskTokenSource.Token);
  334. while (!(downloadTask.IsCompleted || downloadTask.IsCanceled || downloadTask.IsFaulted))
  335. yield return null; // pause coroutine until task is done
  336. if (downloadTask.IsFaulted)
  337. {
  338. if (downloadTask.Exception.InnerExceptions.Where(e => e is ModsaberInterceptException).Any())
  339. { // any exception is an intercept exception
  340. Logger.updater.Error($"Modsaber did not return expected data for {item.Name}");
  341. }
  342. Logger.updater.Error($"Error downloading mod {item.Name}");
  343. Logger.updater.Error(downloadTask.Exception);
  344. continue;
  345. }
  346. break;
  347. }
  348. }
  349. if (maxTries == 0)
  350. Logger.updater.Warn($"Plugin download failed {MaxTries} times, not re-trying");
  351. else
  352. Logger.updater.Debug("Download complete");
  353. }
  354. internal class StreamDownloadHandler : DownloadHandlerScript
  355. {
  356. public MemoryStream Stream { get; set; }
  357. public StreamDownloadHandler(MemoryStream stream) : base()
  358. {
  359. Stream = stream;
  360. }
  361. protected override void ReceiveContentLength(int contentLength)
  362. {
  363. Stream.Capacity = contentLength;
  364. Logger.updater.Debug($"Got content length: {contentLength}");
  365. }
  366. protected override void CompleteContent()
  367. {
  368. Logger.updater.Debug("Download complete");
  369. }
  370. protected override bool ReceiveData(byte[] data, int dataLength)
  371. {
  372. if (data == null || data.Length < 1)
  373. {
  374. Logger.updater.Debug("CustomWebRequest :: ReceiveData - received a null/empty buffer");
  375. return false;
  376. }
  377. Stream.Write(data, 0, dataLength);
  378. return true;
  379. }
  380. protected override byte[] GetData() { return null; }
  381. protected override float GetProgress()
  382. {
  383. return 0f;
  384. }
  385. public override string ToString()
  386. {
  387. return $"{base.ToString()} ({Stream?.ToString()})";
  388. }
  389. }
  390. private void ExtractPluginAsync(MemoryStream stream, DependencyObject item, ApiEndpoint.Mod.PlatformFile fileInfo, string tempDirectory)
  391. { // (3.3)
  392. Logger.updater.Debug($"Extracting ZIP file for {item.Name}");
  393. var data = stream.GetBuffer();
  394. SHA1 sha = new SHA1CryptoServiceProvider();
  395. var hash = sha.ComputeHash(data);
  396. if (!LoneFunctions.UnsafeCompare(hash, fileInfo.Hash))
  397. throw new Exception("The hash for the file doesn't match what is defined");
  398. var newFiles = new List<FileInfo>();
  399. var targetDir = Path.Combine(BeatSaber.InstallPath, "IPA", Path.GetRandomFileName() + "_Pending");
  400. Directory.CreateDirectory(targetDir);
  401. var eventualOutput = Path.Combine(BeatSaber.InstallPath, "IPA", "Pending");
  402. if (!Directory.Exists(eventualOutput))
  403. Directory.CreateDirectory(eventualOutput);
  404. try
  405. {
  406. bool shouldDeleteOldFile = !(item.LocalPluginMeta?.Plugin is SelfPlugin);
  407. using (var zipFile = ZipFile.Read(stream))
  408. {
  409. Logger.updater.Debug("Streams opened");
  410. foreach (var entry in zipFile)
  411. {
  412. if (entry.IsDirectory)
  413. {
  414. Logger.updater.Debug($"Creating directory {entry.FileName}");
  415. Directory.CreateDirectory(Path.Combine(targetDir, entry.FileName));
  416. }
  417. else
  418. {
  419. using (var ostream = new MemoryStream((int)entry.UncompressedSize))
  420. {
  421. entry.Extract(ostream);
  422. ostream.Seek(0, SeekOrigin.Begin);
  423. sha = new SHA1CryptoServiceProvider();
  424. var fileHash = sha.ComputeHash(ostream);
  425. try
  426. {
  427. if (!LoneFunctions.UnsafeCompare(fileHash, fileInfo.FileHashes[entry.FileName]))
  428. throw new Exception("The hash for the file doesn't match what is defined");
  429. }
  430. catch (KeyNotFoundException)
  431. {
  432. throw new ModsaberInterceptException("ModSaber did not send the hashes for the zip's content!");
  433. }
  434. ostream.Seek(0, SeekOrigin.Begin);
  435. FileInfo targetFile = new FileInfo(Path.Combine(targetDir, entry.FileName));
  436. Directory.CreateDirectory(targetFile.DirectoryName);
  437. if (LoneFunctions.GetRelativePath(targetFile.FullName, targetDir) == LoneFunctions.GetRelativePath(item.LocalPluginMeta?.Filename, BeatSaber.InstallPath))
  438. shouldDeleteOldFile = false; // overwriting old file, no need to delete
  439. /*if (targetFile.Exists)
  440. backup.Add(targetFile);
  441. else
  442. newFiles.Add(targetFile);*/
  443. Logger.updater.Debug($"Extracting file {targetFile.FullName}");
  444. targetFile.Delete();
  445. using (var fstream = targetFile.Create())
  446. ostream.CopyTo(fstream);
  447. }
  448. }
  449. }
  450. }
  451. if (shouldDeleteOldFile && item.LocalPluginMeta != null)
  452. File.AppendAllLines(Path.Combine(targetDir, _SpecialDeletionsFile), new string[] { LoneFunctions.GetRelativePath(item.LocalPluginMeta.Filename, BeatSaber.InstallPath) });
  453. }
  454. catch (Exception)
  455. { // something failed; restore
  456. /*foreach (var file in newFiles)
  457. file.Delete();
  458. backup.Restore();
  459. backup.Delete();*/
  460. Directory.Delete(targetDir, true); // delete extraction site
  461. throw;
  462. }
  463. if (item.LocalPluginMeta?.Plugin is SelfPlugin)
  464. { // currently updating self, so copy to working dir and update
  465. LoneFunctions.CopyAll(new DirectoryInfo(targetDir), new DirectoryInfo(BeatSaber.InstallPath));
  466. if (File.Exists(Path.Combine(BeatSaber.InstallPath, _SpecialDeletionsFile))) File.Delete(Path.Combine(BeatSaber.InstallPath, _SpecialDeletionsFile));
  467. Process.Start(new ProcessStartInfo
  468. {
  469. FileName = item.LocalPluginMeta.Filename,
  470. Arguments = $"-nw={Process.GetCurrentProcess().Id}",
  471. UseShellExecute = false
  472. });
  473. }
  474. else
  475. LoneFunctions.CopyAll(new DirectoryInfo(targetDir), new DirectoryInfo(eventualOutput), _SpecialDeletionsFile);
  476. Directory.Delete(targetDir, true); // delete extraction site
  477. Logger.updater.Debug("Extractor exited");
  478. }
  479. internal const string _SpecialDeletionsFile = "$$delete";
  480. }
  481. [Serializable]
  482. internal class NetworkException : Exception
  483. {
  484. public NetworkException()
  485. {
  486. }
  487. public NetworkException(string message) : base(message)
  488. {
  489. }
  490. public NetworkException(string message, Exception innerException) : base(message, innerException)
  491. {
  492. }
  493. protected NetworkException(SerializationInfo info, StreamingContext context) : base(info, context)
  494. {
  495. }
  496. }
  497. [Serializable]
  498. internal class ModsaberInterceptException : Exception
  499. {
  500. public ModsaberInterceptException()
  501. {
  502. }
  503. public ModsaberInterceptException(string message) : base(message)
  504. {
  505. }
  506. public ModsaberInterceptException(string message, Exception innerException) : base(message, innerException)
  507. {
  508. }
  509. protected ModsaberInterceptException(SerializationInfo info, StreamingContext context) : base(info, context)
  510. {
  511. }
  512. }
  513. }