4 using System.Collections.Generic;
5 using System.Collections.Immutable;
6 using System.Diagnostics.CodeAnalysis;
8 using System.Threading;
9 using System.Threading.Tasks;
10 using System.Xml.Linq;
12 using WorkshopItemSet = System.Collections.Generic.ISet<Steamworks.Ugc.Item>;
16 static partial class SteamManager
18 public static bool TryExtractSteamWorkshopId(
this ContentPackage contentPackage, [NotNullWhen(
true)]out SteamWorkshopId? workshopId)
21 if (!contentPackage.UgcId.TryUnwrap(out var ugcId)) {
return false; }
22 if (!(ugcId is SteamWorkshopId steamWorkshopId)) {
return false; }
24 workshopId = steamWorkshopId;
28 public static partial class Workshop
30 private struct ItemEqualityComparer : IEqualityComparer<Steamworks.Ugc.Item>
32 public static readonly ItemEqualityComparer Instance =
new ItemEqualityComparer();
34 public bool Equals(Steamworks.Ugc.Item x, Steamworks.Ugc.Item y)
37 public int GetHashCode(Steamworks.Ugc.Item obj)
41 private static async Task<WorkshopItemSet> GetWorkshopItems(Steamworks.Ugc.Query query,
int? maxPages =
null)
43 if (!IsInitialized) {
return new HashSet<Steamworks.Ugc.Item>(); }
46 var
set =
new HashSet<Steamworks.Ugc.Item>(ItemEqualityComparer.Instance);
48 for (
int i = 1; i <= (maxPages ??
int.MaxValue); i++)
50 using Steamworks.Ugc.ResultPage? page = await query.GetPageAsync(i);
51 if (page is not { Entries: var entries }) {
break; }
55 entries = entries.ToArray();
57 if (entries.None()) {
break; }
59 set.UnionWith(entries);
61 if (
set.Count == prevSize) {
break; }
68 set.RemoveWhere(it => it.ConsumerApp != AppID);
73 public static ImmutableHashSet<Steamworks.Data.PublishedFileId> GetSubscribedItemIds()
76 ? Steamworks.SteamUGC.GetSubscribedItems().ToImmutableHashSet()
77 : ImmutableHashSet<Steamworks.Data.PublishedFileId>.Empty;
80 public static async Task<WorkshopItemSet> GetAllSubscribedItems()
82 if (!IsInitialized) {
return new HashSet<Steamworks.Ugc.Item>(); }
84 return await GetWorkshopItems(
85 Steamworks.Ugc.Query.Items
86 .WhereUserSubscribed());
89 public static async Task<WorkshopItemSet> GetPopularItems()
91 if (!IsInitialized) {
return new HashSet<Steamworks.Ugc.Item>(); }
93 return await GetWorkshopItems(
94 Steamworks.Ugc.Query.Items
96 .RankedByTrend(), maxPages: 1);
99 public static async Task<WorkshopItemSet> GetPublishedItems()
101 if (!IsInitialized) {
return new HashSet<Steamworks.Ugc.Item>(); }
103 return await GetWorkshopItems(
104 Steamworks.Ugc.Query.All
105 .WhereUserPublished());
108 private static class SingleItemRequestPool
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();
114 private static Task<WorkshopItemSet>? currentBatch =
null;
116 private static async Task<WorkshopItemSet> PrepareNewBatch()
119 await Task.Delay(delayAfterNewRequest);
121 Task<WorkshopItemSet> queryTask;
125 $
"{nameof(SteamManager)}.{nameof(Workshop)}.{nameof(SingleItemRequestPool)}: " +
126 $
"Running batch of {ids.Count} requests");
128 queryTask = GetWorkshopItems(
129 Steamworks.Ugc.Query.All
132 .Select(
id => (Steamworks.Data.PublishedFileId)
id)
140 return await queryTask;
143 public static async Task<Option<Steamworks.Ugc.Item>> MakeRequest(UInt64
id)
145 Task<WorkshopItemSet> ourTask;
149 if (currentBatch is not { IsCompleted:
false })
152 currentBatch = Task.Run(PrepareNewBatch);
154 ourTask = currentBatch;
157 var items = await ourTask;
158 var result = items.FirstOrNone(it => it.Id ==
id);
168 public static Task<Option<Steamworks.Ugc.Item>> GetItem(UInt64 itemId)
169 => SingleItemRequestPool.MakeRequest(itemId);
179 public static async Task<Option<Steamworks.Ugc.Item>> GetItemAsap(UInt64 itemId,
bool withLongDescription =
false)
181 if (!IsInitialized) {
return Option.None; }
183 var items = await GetWorkshopItems(
184 Steamworks.Ugc.Query.All
186 .WithLongDescription(withLongDescription));
188 ? Option.Some(items.First())
192 public static async Task ForceRedownload(UInt64 itemId)
193 => await ForceRedownload(
new Steamworks.Ugc.Item(itemId));
195 public static void NukeDownload(Steamworks.Ugc.Item item)
199 System.IO.Directory.Delete(item.Directory ??
"", recursive:
true);
207 public static void Uninstall(Steamworks.Ugc.Item workshopItem)
209 NukeDownload(workshopItem);
211 = ContentPackageManager.WorkshopPackages.Where(p =>
212 p.UgcId.TryUnwrap(out var ugcId)
213 && ugcId is SteamWorkshopId { Value: var itemId }
214 && itemId == workshopItem.Id)
216 ContentPackageManager.EnabledPackages.DisableMods(toUninstall);
217 toUninstall.Select(p => p.Dir).ForEach(d => Directory.Delete(d));
218 ContentPackageManager.WorkshopPackages.Refresh();
219 ContentPackageManager.EnabledPackages.DisableRemovedMods();
222 public static async Task ForceRedownload(Steamworks.Ugc.Item item, CancellationTokenSource? cancellationTokenSrc =
null)
225 cancellationTokenSrc ??=
new CancellationTokenSource();
226 await item.DownloadAsync(ct: cancellationTokenSrc.Token);
234 private class CopyIndicator : IDisposable
236 private readonly
string path;
238 public CopyIndicator(
string path)
241 using (var f = File.Create(path))
245 throw new Exception($
"File.Create returned null");
247 f.WriteByte((
byte)0);
251 public void Dispose()
269 private class InstallTaskCounter : IDisposable
271 private static readonly HashSet<InstallTaskCounter> installers =
new HashSet<InstallTaskCounter>();
272 private readonly
static object mutex =
new object();
273 private const int MaxTasks = 7;
275 private readonly UInt64 itemId;
276 private InstallTaskCounter(UInt64
id) { itemId = id; }
278 public static bool IsInstalling(Steamworks.Ugc.Item item)
279 => IsInstalling(item.Id);
281 public static bool IsInstalling(ulong itemId)
285 return installers.Any(i => i.itemId == itemId);
289 private async Task Init()
296 if (installers.Count < MaxTasks) { installers.Add(
this);
return; }
298 await Task.Delay(5000);
302 public static async Task<InstallTaskCounter> Create(ulong itemId)
304 var retVal =
new InstallTaskCounter(itemId);
309 public void Dispose()
311 lock (mutex) { installers.Remove(
this); }
315 public static bool IsItemDirectoryUpToDate(in Steamworks.Ugc.Item item)
317 string itemDirectory = item.Directory ??
"";
318 return Directory.Exists(itemDirectory)
319 && File.GetLastWriteTime(itemDirectory).ToUniversalTime() >= item.LatestUpdateTime;
322 public static bool CanBeInstalled(ulong itemId)
323 => CanBeInstalled(
new Steamworks.Ugc.Item(itemId));
325 public static bool CanBeInstalled(in Steamworks.Ugc.Item item)
327 bool needsUpdate = item.NeedsUpdate;
328 bool isDownloading = item.IsDownloading;
329 bool isInstalled = item.IsInstalled;
330 bool directoryIsUpToDate = IsItemDirectoryUpToDate(item);
335 && directoryIsUpToDate;
338 public static async Task DownloadModThenEnqueueInstall(Steamworks.Ugc.Item item)
340 if (!CanBeInstalled(item))
342 if (!item.IsDownloading && !item.IsDownloadPending) { await ForceRedownload(item); }
347 OnItemDownloadComplete(item.Id);
352 public static void DeleteFailedCopies()
354 if (Directory.Exists(ContentPackage.WorkshopModsDir))
356 foreach (var dir
in Directory.EnumerateDirectories(ContentPackage.WorkshopModsDir,
"**"))
358 string copyingIndicatorPath = Path.Combine(dir, ContentPackageManager.CopyIndicatorFileName);
359 if (File.Exists(copyingIndicatorPath))
361 Directory.Delete(dir, recursive:
true);
367 public static ISet<ulong> GetInstalledItems()
368 => ContentPackageManager.WorkshopPackages
369 .Select(p => p.UgcId)
371 .OfType<SteamWorkshopId>()
375 public static async Task<ISet<Steamworks.Ugc.Item>> GetPublishedAndSubscribedItems()
377 var allItems = (await GetAllSubscribedItems()).ToHashSet();
378 allItems.UnionWith(await GetPublishedItems());
383 allItems = (await Task.WhenAll(allItems.Select(it => GetItem(it.Id.Value))))
385 .Where(it => it.ConsumerApp == AppID)
391 public static void DeleteUnsubscribedMods(Action<ContentPackage[]>? callback =
null)
398 if (!IsInitialized) {
return; }
399 if (!Steamworks.SteamClient.IsValid) {
return; }
400 if (!Steamworks.SteamClient.IsLoggedOn) {
return; }
402 TaskPool.Add(
"DeleteUnsubscribedMods", GetPublishedAndSubscribedItems().WaitForLoadingScreen(), t =>
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
408 => !pkg.UgcId.TryUnwrap<SteamWorkshopId>(out var workshopId)
409 || !ids.Contains(workshopId.Value))
411 if (toUninstall.Any())
413 foreach (var pkg in toUninstall)
415 Directory.TryDelete(pkg.Dir, recursive: true);
417 ContentPackageManager.UpdateContentPackageList();
419 callback?.Invoke(toUninstall);
423 public static bool IsInstallingToPath(
string path)
424 => File.Exists(Path.Combine(Path.GetDirectoryName(path)!, ContentPackageManager.CopyIndicatorFileName));
426 public static bool IsInstalling(Steamworks.Ugc.Item item)
427 => InstallTaskCounter.IsInstalling(item);
429 private static async Task InstallMod(ulong
id)
431 using var installCounter = await InstallTaskCounter.Create(
id);
433 var itemOption = await GetItem(
id);
434 if (!itemOption.TryUnwrap(out var item)) {
return; }
437 string itemTitle = item.Title?.Trim() ??
"";
438 UInt64 itemId = item.Id;
439 string itemDirectory = item.Directory ??
"";
440 DateTime updateTime = item.LatestUpdateTime;
442 if (!CanBeInstalled(item))
444 ForceRedownload(item);
445 throw new InvalidOperationException($
"Item {itemTitle} (id {itemId}) is not available for copying");
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"
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";
456 string workshopModDirReadmeLocation = Path.Combine(SaveUtil.DefaultSaveFolder,
"WorkshopMods",
"README.txt");
457 if (!File.Exists(workshopModDirReadmeLocation))
459 Directory.CreateDirectory(Path.GetDirectoryName(workshopModDirReadmeLocation)!);
461 path: workshopModDirReadmeLocation,
462 contents: workshopModDirReadme);
465 string installDir = Path.Combine(ContentPackage.WorkshopModsDir, itemId.ToString());
466 Directory.CreateDirectory(installDir);
468 string copyIndicatorPath = Path.Combine(installDir, ContentPackageManager.CopyIndicatorFileName);
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"
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",
"");
482 using (var copyIndicator =
new CopyIndicator(copyIndicatorPath))
484 await CopyDirectory(itemDirectory, modPathDirName ?? modName, itemDirectory, installDir,
485 gameVersion <
new Version(0, 18, 3, 0)
486 ? ShouldCorrectPaths.Yes
487 : ShouldCorrectPaths.No);
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();
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)
504 root.Add(
new XAttribute(
"altnames", modPathDirName ?? modName));
506 if (!expectedHash.IsNullOrEmpty())
508 root.Add(
new XAttribute(
"expectedhash", expectedHash));
510 fileListDest.SaveSafe(fileListDestPath);
514 private static async Task CorrectPaths(
string fileListDir,
string modName, XElement element)
516 foreach (var attribute
in element.Attributes())
520 string val = attribute.Value.CleanUpPathCrossPlatform(correctFilenameCase:
false);
526 int modDirStrIndex = val.IndexOf(ContentPath.ModDirStr, StringComparison.OrdinalIgnoreCase);
527 if (modDirStrIndex >= 0)
529 val = val[modDirStrIndex..];
535 string fullSrcPath = Path.Combine(fileListDir, val).CleanUpPath();
536 if (File.Exists(fullSrcPath))
538 val = $
"{ContentPath.ModDirStr}/{val}";
544 string oldModDir = $
"Mods/{modName}";
545 if (val.StartsWith(oldModDir, StringComparison.OrdinalIgnoreCase))
547 val = $
"{ContentPath.ModDirStr}{val.Remove(0, oldModDir.Length)}";
551 else if (val.StartsWith(
"Mods/", StringComparison.OrdinalIgnoreCase))
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)}";
559 else if (val.StartsWith(
"Submarines/", StringComparison.OrdinalIgnoreCase))
561 val = $
"{ContentPath.ModDirStr}/{val}";
564 if (isPath) { attribute.Value = val; }
568 .Select(subElement => CorrectPaths(
569 fileListDir: fileListDir,
571 element: subElement)));
574 private static async Task CopyFile(
string fileListDir,
string modName,
string from,
string to, ShouldCorrectPaths shouldCorrectPaths)
577 Identifier extension = Path.GetExtension(from).ToIdentifier();
578 if (extension ==
".xml")
582 XDocument? doc = XMLExtensions.TryLoadXml(from, out var exception);
583 if (exception is { Message:
string exceptionMsg })
585 throw new Exception($
"Could not load \"{from}\": {exceptionMsg}");
589 throw new Exception($
"Could not load \"{from}\": doc is null");
592 if (shouldCorrectPaths == ShouldCorrectPaths.Yes)
595 fileListDir: fileListDir,
597 element: doc.Root ??
throw new NullReferenceException());
604 DebugConsole.AddWarning(
605 $
"An exception was thrown when attempting to copy \"{from}\" to \"{to}\": {e.Message}\n{e.StackTrace}");
608 File.Copy(from, to, overwrite:
true);
611 public enum ShouldCorrectPaths
616 public static async Task CopyDirectory(
string fileListDir,
string modName,
string from,
string to, ShouldCorrectPaths shouldCorrectPaths)
618 from = Path.GetFullPath(from);
619 to = Path.GetFullPath(to);
620 Directory.CreateDirectory(to);
622 string convertFromTo(
string from)
623 => Path.Combine(to, Path.GetFileName(from));
625 string[] files = Directory.GetFiles(from);
626 string[] subDirs = Directory.GetDirectories(from);
627 foreach (var file
in files)
630 if (Path.GetFileName(file).StartsWith(
'.')) {
continue; }
631 await CopyFile(fileListDir, modName, file, convertFromTo(file), shouldCorrectPaths);
634 foreach (var dir
in subDirs)
636 if (Path.GetFileName(dir) is { } dirName && dirName.StartsWith(
'.')) {
continue; }
637 await CopyDirectory(fileListDir, modName, dir, convertFromTo(dir), shouldCorrectPaths);