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.

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