Files
LuaCsForBarotraumaEP/Barotrauma/BarotraumaShared/SharedSource/Steam/Workshop.cs
T
Markus Isberg 64db1a6a44 Build 0.18.4.0
2022-05-31 23:13:05 +09:00

475 lines
20 KiB
C#

#nullable enable
using Barotrauma.IO;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Xml.Linq;
using Barotrauma.Extensions;
using Steamworks.Data;
using WorkshopItemSet = System.Collections.Generic.ISet<Steamworks.Ugc.Item>;
namespace Barotrauma.Steam
{
static partial class SteamManager
{
public const string WorkshopItemPreviewImageFolder = "Workshop";
public const string PreviewImageName = "PreviewImage.png";
public const string DefaultPreviewImagePath = "Content/DefaultWorkshopPreviewImage.png";
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();
query = query.WithKeyValueTags(true).WithLongDescription(true);
var set = new HashSet<Steamworks.Ugc.Item>(ItemEqualityComparer.Instance);
int prevSize = 0;
for (int i = 1; maxPages is null || i <= maxPages; i++)
{
Steamworks.Ugc.ResultPage? page = await query.GetPageAsync(i);
if (page is null || !page.Value.Entries.Any()) { break; }
set.UnionWith(page.Value.Entries);
if (set.Count == prevSize) { break; }
prevSize = set.Count;
}
return set;
}
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());
}
public static async Task<Steamworks.Ugc.Item?> GetItem(UInt64 itemId)
{
if (!IsInitialized) { return null; }
var items = await GetWorkshopItems(
Steamworks.Ugc.Query.All
.WithFileId(itemId));
return items.Any() ? items.First() : (Steamworks.Ugc.Item?)null;
}
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.SteamWorkshopId == 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 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 itemNullable = await GetItem(id);
if (!(itemNullable is { } 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.SaveFolder, "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, ShouldCorrectPaths.Yes);
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", itemTitle),
new XAttribute("steamworkshopid", itemId),
new XAttribute("corepackage", isCorePackage),
new XAttribute("modversion", modVersion),
new XAttribute("gameversion", gameVersion),
new XAttribute("installtime", ToolBox.Epoch.FromDateTime(updateTime)));
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.ThrowError(
$"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)
{
await CopyFile(fileListDir, modName, file, convertFromTo(file), shouldCorrectPaths);
}
foreach (var dir in subDirs) { await CopyDirectory(fileListDir, modName, dir, convertFromTo(dir), shouldCorrectPaths); }
}
}
}
}