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.

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