Client LuaCsForBarotrauma
BarotraumaShared/SharedSource/Steam/Workshop.cs
1 #nullable enable
2 using Barotrauma.IO;
3 using System;
4 using System.Collections.Generic;
5 using System.Collections.Immutable;
6 using System.Diagnostics.CodeAnalysis;
7 using System.Linq;
8 using System.Threading;
9 using System.Threading.Tasks;
10 using System.Xml.Linq;
12 using WorkshopItemSet = System.Collections.Generic.ISet<Steamworks.Ugc.Item>;
13 
14 namespace Barotrauma.Steam
15 {
16  static partial class SteamManager
17  {
18  public static bool TryExtractSteamWorkshopId(this ContentPackage contentPackage, [NotNullWhen(true)]out SteamWorkshopId? workshopId)
19  {
20  workshopId = null;
21  if (!contentPackage.UgcId.TryUnwrap(out var ugcId)) { return false; }
22  if (!(ugcId is SteamWorkshopId steamWorkshopId)) { return false; }
23 
24  workshopId = steamWorkshopId;
25  return true;
26  }
27 
28  public static partial class Workshop
29  {
30  private struct ItemEqualityComparer : IEqualityComparer<Steamworks.Ugc.Item>
31  {
32  public static readonly ItemEqualityComparer Instance = new ItemEqualityComparer();
33 
34  public bool Equals(Steamworks.Ugc.Item x, Steamworks.Ugc.Item y)
35  => x.Id == y.Id;
36 
37  public int GetHashCode(Steamworks.Ugc.Item obj)
38  => (int)obj.Id.Value;
39  }
40 
41  private static async Task<WorkshopItemSet> GetWorkshopItems(Steamworks.Ugc.Query query, int? maxPages = null)
42  {
43  if (!IsInitialized) { return new HashSet<Steamworks.Ugc.Item>(); }
44 
45  await Task.Yield();
46  var set = new HashSet<Steamworks.Ugc.Item>(ItemEqualityComparer.Instance);
47  int prevSize = 0;
48  for (int i = 1; i <= (maxPages ?? int.MaxValue); i++)
49  {
50  using Steamworks.Ugc.ResultPage? page = await query.GetPageAsync(i);
51  if (page is not { Entries: var entries }) { break; }
52 
53  // This queries the results on the i-th page and stores them,
54  // using page.Entries directly would result in two GetQueryUGCResult calls
55  entries = entries.ToArray();
56 
57  if (entries.None()) { break; }
58 
59  set.UnionWith(entries);
60 
61  if (set.Count == prevSize) { break; }
62  prevSize = set.Count;
63  }
64 
65  // Remove items that do not have the correct consumer app ID,
66  // which can happen on items that are not visible to the currently
67  // logged in player (i.e. private & friends-only items)
68  set.RemoveWhere(it => it.ConsumerApp != AppID);
69 
70  return set;
71  }
72 
73  public static ImmutableHashSet<Steamworks.Data.PublishedFileId> GetSubscribedItemIds()
74  {
75  return IsInitialized
76  ? Steamworks.SteamUGC.GetSubscribedItems().ToImmutableHashSet()
77  : ImmutableHashSet<Steamworks.Data.PublishedFileId>.Empty;
78  }
79 
80  public static async Task<WorkshopItemSet> GetAllSubscribedItems()
81  {
82  if (!IsInitialized) { return new HashSet<Steamworks.Ugc.Item>(); }
83 
84  return await GetWorkshopItems(
85  Steamworks.Ugc.Query.Items
86  .WhereUserSubscribed());
87  }
88 
89  public static async Task<WorkshopItemSet> GetPopularItems()
90  {
91  if (!IsInitialized) { return new HashSet<Steamworks.Ugc.Item>(); }
92 
93  return await GetWorkshopItems(
94  Steamworks.Ugc.Query.Items
95  .WithTrendDays(7)
96  .RankedByTrend(), maxPages: 1);
97  }
98 
99  public static async Task<WorkshopItemSet> GetPublishedItems()
100  {
101  if (!IsInitialized) { return new HashSet<Steamworks.Ugc.Item>(); }
102 
103  return await GetWorkshopItems(
104  Steamworks.Ugc.Query.All
105  .WhereUserPublished());
106  }
107 
108  private static class SingleItemRequestPool
109  {
110  private static readonly object mutex = new();
111  private static readonly TimeSpan delayAfterNewRequest = TimeSpan.FromSeconds(0.5);
112  private static readonly HashSet<UInt64> ids = new();
113 
114  private static Task<WorkshopItemSet>? currentBatch = null;
115 
116  private static async Task<WorkshopItemSet> PrepareNewBatch()
117  {
118  // Wait for a bunch of requests to be made
119  await Task.Delay(delayAfterNewRequest);
120 
121  Task<WorkshopItemSet> queryTask;
122  lock (mutex)
123  {
124  DebugConsole.Log(
125  $"{nameof(SteamManager)}.{nameof(Workshop)}.{nameof(SingleItemRequestPool)}: " +
126  $"Running batch of {ids.Count} requests");
127 
128  queryTask = GetWorkshopItems(
129  Steamworks.Ugc.Query.All
130  .WithFileId(
131  ids
132  .Select(id => (Steamworks.Data.PublishedFileId)id)
133  .ToArray()));
134  ids.Clear();
135 
136  // Immediately clear the current batch so the next request starts a new one
137  currentBatch = null;
138  }
139 
140  return await queryTask;
141  }
142 
143  public static async Task<Option<Steamworks.Ugc.Item>> MakeRequest(UInt64 id)
144  {
145  Task<WorkshopItemSet> ourTask;
146  lock (mutex)
147  {
148  ids.Add(id);
149  if (currentBatch is not { IsCompleted: false })
150  {
151  // There is no currently pending batch, start a new one
152  currentBatch = Task.Run(PrepareNewBatch);
153  }
154  ourTask = currentBatch;
155  }
156 
157  var items = await ourTask;
158  var result = items.FirstOrNone(it => it.Id == id);
159  return result;
160  }
161  }
162 
168  public static Task<Option<Steamworks.Ugc.Item>> GetItem(UInt64 itemId)
169  => SingleItemRequestPool.MakeRequest(itemId);
170 
179  public static async Task<Option<Steamworks.Ugc.Item>> GetItemAsap(UInt64 itemId, bool withLongDescription = false)
180  {
181  if (!IsInitialized) { return Option.None; }
182 
183  var items = await GetWorkshopItems(
184  Steamworks.Ugc.Query.All
185  .WithFileId(itemId)
186  .WithLongDescription(withLongDescription));
187  return items.Any()
188  ? Option.Some(items.First())
189  : Option.None;
190  }
191 
192  public static async Task ForceRedownload(UInt64 itemId)
193  => await ForceRedownload(new Steamworks.Ugc.Item(itemId));
194 
195  public static void NukeDownload(Steamworks.Ugc.Item item)
196  {
197  try
198  {
199  System.IO.Directory.Delete(item.Directory ?? "", recursive: true);
200  }
201  catch
202  {
203  //don't care in the slightest about what happens here
204  }
205  }
206 
207  public static void Uninstall(Steamworks.Ugc.Item workshopItem)
208  {
209  NukeDownload(workshopItem);
210  var toUninstall
211  = ContentPackageManager.WorkshopPackages.Where(p =>
212  p.UgcId.TryUnwrap(out var ugcId)
213  && ugcId is SteamWorkshopId { Value: var itemId }
214  && itemId == workshopItem.Id)
215  .ToHashSet();
216  ContentPackageManager.EnabledPackages.DisableMods(toUninstall);
217  toUninstall.Select(p => p.Dir).ForEach(d => Directory.Delete(d));
218  ContentPackageManager.WorkshopPackages.Refresh();
219  ContentPackageManager.EnabledPackages.DisableRemovedMods();
220  }
221 
222  public static async Task ForceRedownload(Steamworks.Ugc.Item item, CancellationTokenSource? cancellationTokenSrc = null)
223  {
224  NukeDownload(item);
225  cancellationTokenSrc ??= new CancellationTokenSource();
226  await item.DownloadAsync(ct: cancellationTokenSrc.Token);
227  }
228 
234  private class CopyIndicator : IDisposable
235  {
236  private readonly string path;
237 
238  public CopyIndicator(string path)
239  {
240  this.path = path;
241  using (var f = File.Create(path))
242  {
243  if (f is null)
244  {
245  throw new Exception($"File.Create returned null");
246  }
247  f.WriteByte((byte)0);
248  }
249  }
250 
251  public void Dispose()
252  {
253  try
254  {
255  File.Delete(path);
256  }
257  catch
258  {
259  //don't care!
260  }
261  }
262  }
263 
269  private class InstallTaskCounter : IDisposable
270  {
271  private static readonly HashSet<InstallTaskCounter> installers = new HashSet<InstallTaskCounter>();
272  private readonly static object mutex = new object();
273  private const int MaxTasks = 7;
274 
275  private readonly UInt64 itemId;
276  private InstallTaskCounter(UInt64 id) { itemId = id; }
277 
278  public static bool IsInstalling(Steamworks.Ugc.Item item)
279  => IsInstalling(item.Id);
280 
281  public static bool IsInstalling(ulong itemId)
282  {
283  lock (mutex)
284  {
285  return installers.Any(i => i.itemId == itemId);
286  }
287  }
288 
289  private async Task Init()
290  {
291  await Task.Yield();
292  while (true)
293  {
294  lock (mutex)
295  {
296  if (installers.Count < MaxTasks) { installers.Add(this); return; }
297  }
298  await Task.Delay(5000);
299  }
300  }
301 
302  public static async Task<InstallTaskCounter> Create(ulong itemId)
303  {
304  var retVal = new InstallTaskCounter(itemId);
305  await retVal.Init();
306  return retVal;
307  }
308 
309  public void Dispose()
310  {
311  lock (mutex) { installers.Remove(this); }
312  }
313  }
314 
315  public static bool IsItemDirectoryUpToDate(in Steamworks.Ugc.Item item)
316  {
317  string itemDirectory = item.Directory ?? "";
318  return Directory.Exists(itemDirectory)
319  && File.GetLastWriteTime(itemDirectory).ToUniversalTime() >= item.LatestUpdateTime;
320  }
321 
322  public static bool CanBeInstalled(ulong itemId)
323  => CanBeInstalled(new Steamworks.Ugc.Item(itemId));
324 
325  public static bool CanBeInstalled(in Steamworks.Ugc.Item item)
326  {
327  bool needsUpdate = item.NeedsUpdate;
328  bool isDownloading = item.IsDownloading;
329  bool isInstalled = item.IsInstalled;
330  bool directoryIsUpToDate = IsItemDirectoryUpToDate(item);
331 
332  return !needsUpdate
333  && !isDownloading
334  && isInstalled
335  && directoryIsUpToDate;
336  }
337 
338  public static async Task DownloadModThenEnqueueInstall(Steamworks.Ugc.Item item)
339  {
340  if (!CanBeInstalled(item))
341  {
342  if (!item.IsDownloading && !item.IsDownloadPending) { await ForceRedownload(item); }
343  }
344 #if CLIENT
345  else
346  {
347  OnItemDownloadComplete(item.Id);
348  }
349 #endif
350  }
351 
352  public static void DeleteFailedCopies()
353  {
354  if (Directory.Exists(ContentPackage.WorkshopModsDir))
355  {
356  foreach (var dir in Directory.EnumerateDirectories(ContentPackage.WorkshopModsDir, "**"))
357  {
358  string copyingIndicatorPath = Path.Combine(dir, ContentPackageManager.CopyIndicatorFileName);
359  if (File.Exists(copyingIndicatorPath))
360  {
361  Directory.Delete(dir, recursive: true);
362  }
363  }
364  }
365  }
366 
367  public static ISet<ulong> GetInstalledItems()
368  => ContentPackageManager.WorkshopPackages
369  .Select(p => p.UgcId)
370  .NotNone()
371  .OfType<SteamWorkshopId>()
372  .Select(id => id.Value)
373  .ToHashSet();
374 
375  public static async Task<ISet<Steamworks.Ugc.Item>> GetPublishedAndSubscribedItems()
376  {
377  var allItems = (await GetAllSubscribedItems()).ToHashSet();
378  allItems.UnionWith(await GetPublishedItems());
379 
380  // This is a hack that eliminates subscribed mods that have been
381  // made private. Players cannot download updates for these, so
382  // we treat them as if they were deleted.
383  allItems = (await Task.WhenAll(allItems.Select(it => GetItem(it.Id.Value))))
384  .NotNone()
385  .Where(it => it.ConsumerApp == AppID)
386  .ToHashSet();
387 
388  return allItems;
389  }
390 
391  public static void DeleteUnsubscribedMods(Action<ContentPackage[]>? callback = null)
392  {
393 #if SERVER
394  // Servers do not run this because they can't subscribe to anything
395  return;
396 #endif
397  //If Steamworks isn't initialized then we can't know what the user has unsubscribed from
398  if (!IsInitialized) { return; }
399  if (!Steamworks.SteamClient.IsValid) { return; }
400  if (!Steamworks.SteamClient.IsLoggedOn) { return; }
401 
402  TaskPool.Add("DeleteUnsubscribedMods", GetPublishedAndSubscribedItems().WaitForLoadingScreen(), t =>
403  {
404  if (!t.TryGetResult(out ISet<Steamworks.Ugc.Item>? items)) { return; }
405  var ids = items.Select(it => it.Id.Value).ToHashSet();
406  var toUninstall = ContentPackageManager.WorkshopPackages
407  .Where(pkg
408  => !pkg.UgcId.TryUnwrap<SteamWorkshopId>(out var workshopId)
409  || !ids.Contains(workshopId.Value))
410  .ToArray();
411  if (toUninstall.Any())
412  {
413  foreach (var pkg in toUninstall)
414  {
415  Directory.TryDelete(pkg.Dir, recursive: true);
416  }
417  ContentPackageManager.UpdateContentPackageList();
418  }
419  callback?.Invoke(toUninstall);
420  });
421  }
422 
423  public static bool IsInstallingToPath(string path)
424  => File.Exists(Path.Combine(Path.GetDirectoryName(path)!, ContentPackageManager.CopyIndicatorFileName));
425 
426  public static bool IsInstalling(Steamworks.Ugc.Item item)
427  => InstallTaskCounter.IsInstalling(item);
428 
429  private static async Task InstallMod(ulong id)
430  {
431  using var installCounter = await InstallTaskCounter.Create(id);
432 
433  var itemOption = await GetItem(id);
434  if (!itemOption.TryUnwrap(out var item)) { return; }
435  await Task.Yield();
436 
437  string itemTitle = item.Title?.Trim() ?? "";
438  UInt64 itemId = item.Id;
439  string itemDirectory = item.Directory ?? "";
440  DateTime updateTime = item.LatestUpdateTime;
441 
442  if (!CanBeInstalled(item))
443  {
444  ForceRedownload(item);
445  throw new InvalidOperationException($"Item {itemTitle} (id {itemId}) is not available for copying");
446  }
447 
448  const string workshopModDirReadme =
449  "DO NOT MODIFY THE CONTENTS OF THIS FOLDER, EVEN IF\n"
450  + "YOU ARE EDITING A MOD YOU PUBLISHED YOURSELF.\n"
451  + "\n"
452  + "If you do you may run into networking issues and\n"
453  + "unexpected deletion of your hard work.\n"
454  + "Instead, modify a copy of your mod in LocalMods.\n";
455 
456  string workshopModDirReadmeLocation = Path.Combine(SaveUtil.DefaultSaveFolder, "WorkshopMods", "README.txt");
457  if (!File.Exists(workshopModDirReadmeLocation))
458  {
459  Directory.CreateDirectory(Path.GetDirectoryName(workshopModDirReadmeLocation)!);
460  File.WriteAllText(
461  path: workshopModDirReadmeLocation,
462  contents: workshopModDirReadme);
463  }
464 
465  string installDir = Path.Combine(ContentPackage.WorkshopModsDir, itemId.ToString());
466  Directory.CreateDirectory(installDir);
467 
468  string copyIndicatorPath = Path.Combine(installDir, ContentPackageManager.CopyIndicatorFileName);
469 
470  XDocument fileListSrc = XMLExtensions.TryLoadXml(Path.Combine(itemDirectory, ContentPackage.FileListFileName));
471  string modName = fileListSrc.Root.GetAttributeString("name", item.Title).Trim();
472  string[] modPathSplit = fileListSrc.Root.GetAttributeString("path", "")
473  .CleanUpPathCrossPlatform(correctFilenameCase: false).Split("/");
474  string? modPathDirName = modPathSplit.Length > 1 && modPathSplit[0] == "Mods"
475  ? modPathSplit[1]
476  : null;
477  string modVersion = fileListSrc.Root.GetAttributeString("modversion", ContentPackage.DefaultModVersion);
478  Version gameVersion = fileListSrc.Root.GetAttributeVersion("gameversion", GameMain.Version);
479  bool isCorePackage = fileListSrc.Root.GetAttributeBool("corepackage", false);
480  string expectedHash = fileListSrc.Root.GetAttributeString("expectedhash", "");
481 
482  using (var copyIndicator = new CopyIndicator(copyIndicatorPath))
483  {
484  await CopyDirectory(itemDirectory, modPathDirName ?? modName, itemDirectory, installDir,
485  gameVersion < new Version(0, 18, 3, 0)
486  ? ShouldCorrectPaths.Yes
487  : ShouldCorrectPaths.No);
488 
489  string fileListDestPath = Path.Combine(installDir, ContentPackage.FileListFileName);
490  XDocument fileListDest = XMLExtensions.TryLoadXml(fileListDestPath);
491  XElement root = fileListDest.Root ?? throw new NullReferenceException("Unable to install mod: file list root is null.");
492  root.Attributes().Remove();
493 
494  root.Add(
495  new XAttribute("name", modName),
496  new XAttribute("steamworkshopid", itemId),
497  new XAttribute("corepackage", isCorePackage),
498  new XAttribute("modversion", modVersion),
499  new XAttribute("gameversion", gameVersion),
500  #warning TODO: stop writing Unix time after this gets on main
501  new XAttribute("installtime", new SerializableDateTime(updateTime).ToUnixTime()));
502  if ((modPathDirName ?? modName).ToIdentifier() != itemTitle)
503  {
504  root.Add(new XAttribute("altnames", modPathDirName ?? modName));
505  }
506  if (!expectedHash.IsNullOrEmpty())
507  {
508  root.Add(new XAttribute("expectedhash", expectedHash));
509  }
510  fileListDest.SaveSafe(fileListDestPath);
511  }
512  }
513 
514  private static async Task CorrectPaths(string fileListDir, string modName, XElement element)
515  {
516  foreach (var attribute in element.Attributes())
517  {
518  await Task.Yield();
519 
520  string val = attribute.Value.CleanUpPathCrossPlatform(correctFilenameCase: false);
521 
522  bool isPath = false;
523 
524  //Handle mods that have been mangled by pre-modding-refactor
525  //copying of post-modding-refactor mods (what a clusterfuck)
526  int modDirStrIndex = val.IndexOf(ContentPath.ModDirStr, StringComparison.OrdinalIgnoreCase);
527  if (modDirStrIndex >= 0)
528  {
529  val = val[modDirStrIndex..];
530  isPath = true;
531  }
532 
533  //Handle really old mods (0.9.0.4-era) that might be structured as
534  //%ModDir%/Mods/[NAME]/[RESOURCE]
535  string fullSrcPath = Path.Combine(fileListDir, val).CleanUpPath();
536  if (File.Exists(fullSrcPath))
537  {
538  val = $"{ContentPath.ModDirStr}/{val}";
539  isPath = true;
540  }
541 
542  //Handle old mods that installed to the fixed Mods directory
543  //that no longer exists
544  string oldModDir = $"Mods/{modName}";
545  if (val.StartsWith(oldModDir, StringComparison.OrdinalIgnoreCase))
546  {
547  val = $"{ContentPath.ModDirStr}{val.Remove(0, oldModDir.Length)}";
548  isPath = true;
549  }
550  //Handle old mods that depend on other mods
551  else if (val.StartsWith("Mods/", StringComparison.OrdinalIgnoreCase))
552  {
553  string otherModName = val.Substring(val.IndexOf('/')+1);
554  otherModName = otherModName.Substring(0, otherModName.IndexOf('/'));
555  val = $"{string.Format(ContentPath.OtherModDirFmt, otherModName)}{val.Remove(0, $"Mods/{otherModName}".Length)}";
556  isPath = true;
557  }
558  //Handle really old mods that installed Submarines in the wrong place
559  else if (val.StartsWith("Submarines/", StringComparison.OrdinalIgnoreCase))
560  {
561  val = $"{ContentPath.ModDirStr}/{val}";
562  isPath = true;
563  }
564  if (isPath) { attribute.Value = val; }
565  }
566  await Task.WhenAll(
567  element.Elements()
568  .Select(subElement => CorrectPaths(
569  fileListDir: fileListDir,
570  modName: modName,
571  element: subElement)));
572  }
573 
574  private static async Task CopyFile(string fileListDir, string modName, string from, string to, ShouldCorrectPaths shouldCorrectPaths)
575  {
576  await Task.Yield();
577  Identifier extension = Path.GetExtension(from).ToIdentifier();
578  if (extension == ".xml")
579  {
580  try
581  {
582  XDocument? doc = XMLExtensions.TryLoadXml(from, out var exception);
583  if (exception is { Message: string exceptionMsg })
584  {
585  throw new Exception($"Could not load \"{from}\": {exceptionMsg}");
586  }
587  if (doc is null)
588  {
589  throw new Exception($"Could not load \"{from}\": doc is null");
590  }
591 
592  if (shouldCorrectPaths == ShouldCorrectPaths.Yes)
593  {
594  await CorrectPaths(
595  fileListDir: fileListDir,
596  modName: modName,
597  element: doc.Root ?? throw new NullReferenceException());
598  }
599  doc.SaveSafe(to);
600  return;
601  }
602  catch (Exception e)
603  {
604  DebugConsole.AddWarning(
605  $"An exception was thrown when attempting to copy \"{from}\" to \"{to}\": {e.Message}\n{e.StackTrace}");
606  }
607  }
608  File.Copy(from, to, overwrite: true);
609  }
610 
611  public enum ShouldCorrectPaths
612  {
613  Yes, No
614  }
615 
616  public static async Task CopyDirectory(string fileListDir, string modName, string from, string to, ShouldCorrectPaths shouldCorrectPaths)
617  {
618  from = Path.GetFullPath(from);
619  to = Path.GetFullPath(to);
620  Directory.CreateDirectory(to);
621 
622  string convertFromTo(string from)
623  => Path.Combine(to, Path.GetFileName(from));
624 
625  string[] files = Directory.GetFiles(from);
626  string[] subDirs = Directory.GetDirectories(from);
627  foreach (var file in files)
628  {
629  //ignore hidden files
630  if (Path.GetFileName(file).StartsWith('.')) { continue; }
631  await CopyFile(fileListDir, modName, file, convertFromTo(file), shouldCorrectPaths);
632  }
633 
634  foreach (var dir in subDirs)
635  {
636  if (Path.GetFileName(dir) is { } dirName && dirName.StartsWith('.')) { continue; }
637  await CopyDirectory(fileListDir, modName, dir, convertFromTo(dir), shouldCorrectPaths);
638  }
639  }
640  }
641  }
642 }