Files
LuaCsForBarotraumaEP/Barotrauma/BarotraumaShared/SharedSource/Steam/Workshop.cs
2024-03-28 18:34:33 +02:00

643 lines
28 KiB
C#

#nullable enable
using Barotrauma.IO;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Xml.Linq;
using Barotrauma.Extensions;
using WorkshopItemSet = System.Collections.Generic.ISet<Steamworks.Ugc.Item>;
namespace Barotrauma.Steam
{
static partial class SteamManager
{
public static bool TryExtractSteamWorkshopId(this ContentPackage contentPackage, [NotNullWhen(true)]out SteamWorkshopId? workshopId)
{
workshopId = null;
if (!contentPackage.UgcId.TryUnwrap(out var ugcId)) { return false; }
if (!(ugcId is SteamWorkshopId steamWorkshopId)) { return false; }
workshopId = steamWorkshopId;
return true;
}
public static partial class Workshop
{
private struct ItemEqualityComparer : IEqualityComparer<Steamworks.Ugc.Item>
{
public static readonly ItemEqualityComparer Instance = new ItemEqualityComparer();
public bool Equals(Steamworks.Ugc.Item x, Steamworks.Ugc.Item y)
=> x.Id == y.Id;
public int GetHashCode(Steamworks.Ugc.Item obj)
=> (int)obj.Id.Value;
}
private static async Task<WorkshopItemSet> GetWorkshopItems(Steamworks.Ugc.Query query, int? maxPages = null)
{
if (!IsInitialized) { return new HashSet<Steamworks.Ugc.Item>(); }
await Task.Yield();
var set = new HashSet<Steamworks.Ugc.Item>(ItemEqualityComparer.Instance);
int prevSize = 0;
for (int i = 1; i <= (maxPages ?? int.MaxValue); i++)
{
using Steamworks.Ugc.ResultPage? page = await query.GetPageAsync(i);
if (page is not { Entries: var entries }) { break; }
// This queries the results on the i-th page and stores them,
// using page.Entries directly would result in two GetQueryUGCResult calls
entries = entries.ToArray();
if (entries.None()) { break; }
set.UnionWith(entries);
if (set.Count == prevSize) { break; }
prevSize = set.Count;
}
// Remove items that do not have the correct consumer app ID,
// which can happen on items that are not visible to the currently
// logged in player (i.e. private & friends-only items)
set.RemoveWhere(it => it.ConsumerApp != AppID);
return set;
}
public static ImmutableHashSet<Steamworks.Data.PublishedFileId> GetSubscribedItemIds()
{
return IsInitialized
? Steamworks.SteamUGC.GetSubscribedItems().ToImmutableHashSet()
: ImmutableHashSet<Steamworks.Data.PublishedFileId>.Empty;
}
public static async Task<WorkshopItemSet> GetAllSubscribedItems()
{
if (!IsInitialized) { return new HashSet<Steamworks.Ugc.Item>(); }
return await GetWorkshopItems(
Steamworks.Ugc.Query.Items
.WhereUserSubscribed());
}
public static async Task<WorkshopItemSet> GetPopularItems()
{
if (!IsInitialized) { return new HashSet<Steamworks.Ugc.Item>(); }
return await GetWorkshopItems(
Steamworks.Ugc.Query.Items
.WithTrendDays(7)
.RankedByTrend(), maxPages: 1);
}
public static async Task<WorkshopItemSet> GetPublishedItems()
{
if (!IsInitialized) { return new HashSet<Steamworks.Ugc.Item>(); }
return await GetWorkshopItems(
Steamworks.Ugc.Query.All
.WhereUserPublished());
}
private static class SingleItemRequestPool
{
private static readonly object mutex = new();
private static readonly TimeSpan delayAfterNewRequest = TimeSpan.FromSeconds(0.5);
private static readonly HashSet<UInt64> ids = new();
private static Task<WorkshopItemSet>? currentBatch = null;
private static async Task<WorkshopItemSet> PrepareNewBatch()
{
// Wait for a bunch of requests to be made
await Task.Delay(delayAfterNewRequest);
Task<WorkshopItemSet> queryTask;
lock (mutex)
{
DebugConsole.Log(
$"{nameof(SteamManager)}.{nameof(Workshop)}.{nameof(SingleItemRequestPool)}: " +
$"Running batch of {ids.Count} requests");
queryTask = GetWorkshopItems(
Steamworks.Ugc.Query.All
.WithFileId(
ids
.Select(id => (Steamworks.Data.PublishedFileId)id)
.ToArray()));
ids.Clear();
// Immediately clear the current batch so the next request starts a new one
currentBatch = null;
}
return await queryTask;
}
public static async Task<Option<Steamworks.Ugc.Item>> MakeRequest(UInt64 id)
{
Task<WorkshopItemSet> ourTask;
lock (mutex)
{
ids.Add(id);
if (currentBatch is not { IsCompleted: false })
{
// There is no currently pending batch, start a new one
currentBatch = Task.Run(PrepareNewBatch);
}
ourTask = currentBatch;
}
var items = await ourTask;
var result = items.FirstOrNone(it => it.Id == id);
return result;
}
}
/// <summary>
/// Fetches a Workshop item's metadata. This is batched to minimize Steamworks API calls.
/// The description of the returned item is truncated to save bandwidth.
/// </summary>
/// <param name="itemId">Workshop Item ID</param>
public static Task<Option<Steamworks.Ugc.Item>> GetItem(UInt64 itemId)
=> SingleItemRequestPool.MakeRequest(itemId);
/// <summary>
/// Fetches a Workshop item's metadata in its own API call instead of batching.
/// This minimizes delay but needs to be used with caution to prevent rate limiting.
/// </summary>
/// <param name="itemId">Workshop Item ID</param>
/// <param name="withLongDescription">
/// If true, ask for the item's entire description, otherwise it'll be truncated.
/// </param>
public static async Task<Option<Steamworks.Ugc.Item>> GetItemAsap(UInt64 itemId, bool withLongDescription = false)
{
if (!IsInitialized) { return Option.None; }
var items = await GetWorkshopItems(
Steamworks.Ugc.Query.All
.WithFileId(itemId)
.WithLongDescription(withLongDescription));
return items.Any()
? Option.Some(items.First())
: Option.None;
}
public static async Task ForceRedownload(UInt64 itemId)
=> await ForceRedownload(new Steamworks.Ugc.Item(itemId));
public static void NukeDownload(Steamworks.Ugc.Item item)
{
try
{
System.IO.Directory.Delete(item.Directory ?? "", recursive: true);
}
catch
{
//don't care in the slightest about what happens here
}
}
public static void Uninstall(Steamworks.Ugc.Item workshopItem)
{
NukeDownload(workshopItem);
var toUninstall
= ContentPackageManager.WorkshopPackages.Where(p =>
p.UgcId.TryUnwrap(out var ugcId)
&& ugcId is SteamWorkshopId { Value: var itemId }
&& itemId == workshopItem.Id)
.ToHashSet();
ContentPackageManager.EnabledPackages.DisableMods(toUninstall);
toUninstall.Select(p => p.Dir).ForEach(d => Directory.Delete(d));
ContentPackageManager.WorkshopPackages.Refresh();
ContentPackageManager.EnabledPackages.DisableRemovedMods();
}
public static async Task ForceRedownload(Steamworks.Ugc.Item item, CancellationTokenSource? cancellationTokenSrc = null)
{
NukeDownload(item);
cancellationTokenSrc ??= new CancellationTokenSource();
await item.DownloadAsync(ct: cancellationTokenSrc.Token);
}
/// <summary>
/// This class creates a file called ".copying" that
/// serves to keep mod copy operations in the same
/// directory from overlapping.
/// </summary>
private class CopyIndicator : IDisposable
{
private readonly string path;
public CopyIndicator(string path)
{
this.path = path;
using (var f = File.Create(path))
{
if (f is null)
{
throw new Exception($"File.Create returned null");
}
f.WriteByte((byte)0);
}
}
public void Dispose()
{
try
{
File.Delete(path);
}
catch
{
//don't care!
}
}
}
/// <summary>
/// This class serves the purpose of preventing
/// more than 10 mod install tasks from proceeding
/// at the same time.
/// </summary>
private class InstallTaskCounter : IDisposable
{
private static readonly HashSet<InstallTaskCounter> installers = new HashSet<InstallTaskCounter>();
private readonly static object mutex = new object();
private const int MaxTasks = 7;
private readonly UInt64 itemId;
private InstallTaskCounter(UInt64 id) { itemId = id; }
public static bool IsInstalling(Steamworks.Ugc.Item item)
=> IsInstalling(item.Id);
public static bool IsInstalling(ulong itemId)
{
lock (mutex)
{
return installers.Any(i => i.itemId == itemId);
}
}
private async Task Init()
{
await Task.Yield();
while (true)
{
lock (mutex)
{
if (installers.Count < MaxTasks) { installers.Add(this); return; }
}
await Task.Delay(5000);
}
}
public static async Task<InstallTaskCounter> Create(ulong itemId)
{
var retVal = new InstallTaskCounter(itemId);
await retVal.Init();
return retVal;
}
public void Dispose()
{
lock (mutex) { installers.Remove(this); }
}
}
public static bool IsItemDirectoryUpToDate(in Steamworks.Ugc.Item item)
{
string itemDirectory = item.Directory ?? "";
return Directory.Exists(itemDirectory)
&& File.GetLastWriteTime(itemDirectory).ToUniversalTime() >= item.LatestUpdateTime;
}
public static bool CanBeInstalled(ulong itemId)
=> CanBeInstalled(new Steamworks.Ugc.Item(itemId));
public static bool CanBeInstalled(in Steamworks.Ugc.Item item)
{
bool needsUpdate = item.NeedsUpdate;
bool isDownloading = item.IsDownloading;
bool isInstalled = item.IsInstalled;
bool directoryIsUpToDate = IsItemDirectoryUpToDate(item);
return !needsUpdate
&& !isDownloading
&& isInstalled
&& directoryIsUpToDate;
}
public static async Task DownloadModThenEnqueueInstall(Steamworks.Ugc.Item item)
{
if (!CanBeInstalled(item))
{
if (!item.IsDownloading && !item.IsDownloadPending) { await ForceRedownload(item); }
}
#if CLIENT
else
{
OnItemDownloadComplete(item.Id);
}
#endif
}
public static void DeleteFailedCopies()
{
if (Directory.Exists(ContentPackage.WorkshopModsDir))
{
foreach (var dir in Directory.EnumerateDirectories(ContentPackage.WorkshopModsDir, "**"))
{
string copyingIndicatorPath = Path.Combine(dir, ContentPackageManager.CopyIndicatorFileName);
if (File.Exists(copyingIndicatorPath))
{
Directory.Delete(dir, recursive: true);
}
}
}
}
public static ISet<ulong> GetInstalledItems()
=> ContentPackageManager.WorkshopPackages
.Select(p => p.UgcId)
.NotNone()
.OfType<SteamWorkshopId>()
.Select(id => id.Value)
.ToHashSet();
public static async Task<ISet<Steamworks.Ugc.Item>> GetPublishedAndSubscribedItems()
{
var allItems = (await GetAllSubscribedItems()).ToHashSet();
allItems.UnionWith(await GetPublishedItems());
// This is a hack that eliminates subscribed mods that have been
// made private. Players cannot download updates for these, so
// we treat them as if they were deleted.
allItems = (await Task.WhenAll(allItems.Select(it => GetItem(it.Id.Value))))
.NotNone()
.Where(it => it.ConsumerApp == AppID)
.ToHashSet();
return allItems;
}
public static void DeleteUnsubscribedMods(Action<ContentPackage[]>? callback = null)
{
#if SERVER
// Servers do not run this because they can't subscribe to anything
return;
#endif
//If Steamworks isn't initialized then we can't know what the user has unsubscribed from
if (!IsInitialized) { return; }
if (!Steamworks.SteamClient.IsValid) { return; }
if (!Steamworks.SteamClient.IsLoggedOn) { return; }
TaskPool.Add("DeleteUnsubscribedMods", GetPublishedAndSubscribedItems().WaitForLoadingScreen(), t =>
{
if (!t.TryGetResult(out ISet<Steamworks.Ugc.Item>? items)) { return; }
var ids = items.Select(it => it.Id.Value).ToHashSet();
var toUninstall = ContentPackageManager.WorkshopPackages
.Where(pkg
=> !pkg.UgcId.TryUnwrap<SteamWorkshopId>(out var workshopId)
|| !ids.Contains(workshopId.Value))
.ToArray();
if (toUninstall.Any())
{
foreach (var pkg in toUninstall)
{
Directory.TryDelete(pkg.Dir, recursive: true);
}
ContentPackageManager.UpdateContentPackageList();
}
callback?.Invoke(toUninstall);
});
}
public static bool IsInstallingToPath(string path)
=> File.Exists(Path.Combine(Path.GetDirectoryName(path)!, ContentPackageManager.CopyIndicatorFileName));
public static bool IsInstalling(Steamworks.Ugc.Item item)
=> InstallTaskCounter.IsInstalling(item);
private static async Task InstallMod(ulong id)
{
using var installCounter = await InstallTaskCounter.Create(id);
var itemOption = await GetItem(id);
if (!itemOption.TryUnwrap(out var item)) { return; }
await Task.Yield();
string itemTitle = item.Title?.Trim() ?? "";
UInt64 itemId = item.Id;
string itemDirectory = item.Directory ?? "";
DateTime updateTime = item.LatestUpdateTime;
if (!CanBeInstalled(item))
{
ForceRedownload(item);
throw new InvalidOperationException($"Item {itemTitle} (id {itemId}) is not available for copying");
}
const string workshopModDirReadme =
"DO NOT MODIFY THE CONTENTS OF THIS FOLDER, EVEN IF\n"
+ "YOU ARE EDITING A MOD YOU PUBLISHED YOURSELF.\n"
+ "\n"
+ "If you do you may run into networking issues and\n"
+ "unexpected deletion of your hard work.\n"
+ "Instead, modify a copy of your mod in LocalMods.\n";
string workshopModDirReadmeLocation = Path.Combine(SaveUtil.DefaultSaveFolder, "WorkshopMods", "README.txt");
if (!File.Exists(workshopModDirReadmeLocation))
{
Directory.CreateDirectory(Path.GetDirectoryName(workshopModDirReadmeLocation)!);
File.WriteAllText(
path: workshopModDirReadmeLocation,
contents: workshopModDirReadme);
}
string installDir = Path.Combine(ContentPackage.WorkshopModsDir, itemId.ToString());
Directory.CreateDirectory(installDir);
string copyIndicatorPath = Path.Combine(installDir, ContentPackageManager.CopyIndicatorFileName);
XDocument fileListSrc = XMLExtensions.TryLoadXml(Path.Combine(itemDirectory, ContentPackage.FileListFileName));
string modName = fileListSrc.Root.GetAttributeString("name", item.Title).Trim();
string[] modPathSplit = fileListSrc.Root.GetAttributeString("path", "")
.CleanUpPathCrossPlatform(correctFilenameCase: false).Split("/");
string? modPathDirName = modPathSplit.Length > 1 && modPathSplit[0] == "Mods"
? modPathSplit[1]
: null;
string modVersion = fileListSrc.Root.GetAttributeString("modversion", ContentPackage.DefaultModVersion);
Version gameVersion = fileListSrc.Root.GetAttributeVersion("gameversion", GameMain.Version);
bool isCorePackage = fileListSrc.Root.GetAttributeBool("corepackage", false);
string expectedHash = fileListSrc.Root.GetAttributeString("expectedhash", "");
using (var copyIndicator = new CopyIndicator(copyIndicatorPath))
{
await CopyDirectory(itemDirectory, modPathDirName ?? modName, itemDirectory, installDir,
gameVersion < new Version(0, 18, 3, 0)
? ShouldCorrectPaths.Yes
: ShouldCorrectPaths.No);
string fileListDestPath = Path.Combine(installDir, ContentPackage.FileListFileName);
XDocument fileListDest = XMLExtensions.TryLoadXml(fileListDestPath);
XElement root = fileListDest.Root ?? throw new NullReferenceException("Unable to install mod: file list root is null.");
root.Attributes().Remove();
root.Add(
new XAttribute("name", modName),
new XAttribute("steamworkshopid", itemId),
new XAttribute("corepackage", isCorePackage),
new XAttribute("modversion", modVersion),
new XAttribute("gameversion", gameVersion),
#warning TODO: stop writing Unix time after this gets on main
new XAttribute("installtime", new SerializableDateTime(updateTime).ToUnixTime()));
if ((modPathDirName ?? modName).ToIdentifier() != itemTitle)
{
root.Add(new XAttribute("altnames", modPathDirName ?? modName));
}
if (!expectedHash.IsNullOrEmpty())
{
root.Add(new XAttribute("expectedhash", expectedHash));
}
fileListDest.SaveSafe(fileListDestPath);
}
}
private static async Task CorrectPaths(string fileListDir, string modName, XElement element)
{
foreach (var attribute in element.Attributes())
{
await Task.Yield();
string val = attribute.Value.CleanUpPathCrossPlatform(correctFilenameCase: false);
bool isPath = false;
//Handle mods that have been mangled by pre-modding-refactor
//copying of post-modding-refactor mods (what a clusterfuck)
int modDirStrIndex = val.IndexOf(ContentPath.ModDirStr, StringComparison.OrdinalIgnoreCase);
if (modDirStrIndex >= 0)
{
val = val[modDirStrIndex..];
isPath = true;
}
//Handle really old mods (0.9.0.4-era) that might be structured as
//%ModDir%/Mods/[NAME]/[RESOURCE]
string fullSrcPath = Path.Combine(fileListDir, val).CleanUpPath();
if (File.Exists(fullSrcPath))
{
val = $"{ContentPath.ModDirStr}/{val}";
isPath = true;
}
//Handle old mods that installed to the fixed Mods directory
//that no longer exists
string oldModDir = $"Mods/{modName}";
if (val.StartsWith(oldModDir, StringComparison.OrdinalIgnoreCase))
{
val = $"{ContentPath.ModDirStr}{val.Remove(0, oldModDir.Length)}";
isPath = true;
}
//Handle old mods that depend on other mods
else if (val.StartsWith("Mods/", StringComparison.OrdinalIgnoreCase))
{
string otherModName = val.Substring(val.IndexOf('/')+1);
otherModName = otherModName.Substring(0, otherModName.IndexOf('/'));
val = $"{string.Format(ContentPath.OtherModDirFmt, otherModName)}{val.Remove(0, $"Mods/{otherModName}".Length)}";
isPath = true;
}
//Handle really old mods that installed Submarines in the wrong place
else if (val.StartsWith("Submarines/", StringComparison.OrdinalIgnoreCase))
{
val = $"{ContentPath.ModDirStr}/{val}";
isPath = true;
}
if (isPath) { attribute.Value = val; }
}
await Task.WhenAll(
element.Elements()
.Select(subElement => CorrectPaths(
fileListDir: fileListDir,
modName: modName,
element: subElement)));
}
private static async Task CopyFile(string fileListDir, string modName, string from, string to, ShouldCorrectPaths shouldCorrectPaths)
{
await Task.Yield();
Identifier extension = Path.GetExtension(from).ToIdentifier();
if (extension == ".xml")
{
try
{
XDocument? doc = XMLExtensions.TryLoadXml(from, out var exception);
if (exception is { Message: string exceptionMsg })
{
throw new Exception($"Could not load \"{from}\": {exceptionMsg}");
}
if (doc is null)
{
throw new Exception($"Could not load \"{from}\": doc is null");
}
if (shouldCorrectPaths == ShouldCorrectPaths.Yes)
{
await CorrectPaths(
fileListDir: fileListDir,
modName: modName,
element: doc.Root ?? throw new NullReferenceException());
}
doc.SaveSafe(to);
return;
}
catch (Exception e)
{
DebugConsole.AddWarning(
$"An exception was thrown when attempting to copy \"{from}\" to \"{to}\": {e.Message}\n{e.StackTrace}");
}
}
File.Copy(from, to, overwrite: true);
}
public enum ShouldCorrectPaths
{
Yes, No
}
public static async Task CopyDirectory(string fileListDir, string modName, string from, string to, ShouldCorrectPaths shouldCorrectPaths)
{
from = Path.GetFullPath(from);
to = Path.GetFullPath(to);
Directory.CreateDirectory(to);
string convertFromTo(string from)
=> Path.Combine(to, Path.GetFileName(from));
string[] files = Directory.GetFiles(from);
string[] subDirs = Directory.GetDirectories(from);
foreach (var file in files)
{
//ignore hidden files
if (Path.GetFileName(file).StartsWith('.')) { continue; }
await CopyFile(fileListDir, modName, file, convertFromTo(file), shouldCorrectPaths);
}
foreach (var dir in subDirs)
{
if (Path.GetFileName(dir) is { } dirName && dirName.StartsWith('.')) { continue; }
await CopyDirectory(fileListDir, modName, dir, convertFromTo(dir), shouldCorrectPaths);
}
}
}
}
}