using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Numerics; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using BoneHub.Api; using BoneHub.Avatars; using BoneHub.Configuration; using BoneHub.Downloads; using BoneHub.Installation; using BoneHub.Interaction; using BoneHub.Models; using BoneHub.Persistence; using Microsoft.CodeAnalysis; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: TargetFramework(".NETCoreApp,Version=v6.0", FrameworkDisplayName = ".NET 6.0")] [assembly: AssemblyCompany("WristHub.Core")] [assembly: AssemblyConfiguration("Release")] [assembly: AssemblyFileVersion("2.0.1.0")] [assembly: AssemblyInformationalVersion("2.0.1")] [assembly: AssemblyProduct("WristHub.Core")] [assembly: AssemblyTitle("WristHub.Core")] [assembly: AssemblyVersion("2.0.1.0")] [module: RefSafetyRules(11)] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Event | AttributeTargets.Parameter | AttributeTargets.ReturnValue | AttributeTargets.GenericParameter, AllowMultiple = false, Inherited = false)] internal sealed class NullableAttribute : Attribute { public readonly byte[] NullableFlags; public NullableAttribute(byte P_0) { NullableFlags = new byte[1] { P_0 }; } public NullableAttribute(byte[] P_0) { NullableFlags = P_0; } } [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Method | AttributeTargets.Interface | AttributeTargets.Delegate, AllowMultiple = false, Inherited = false)] internal sealed class NullableContextAttribute : Attribute { public readonly byte Flag; public NullableContextAttribute(byte P_0) { Flag = P_0; } } [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)] internal sealed class RefSafetyRulesAttribute : Attribute { public readonly int Version; public RefSafetyRulesAttribute(int P_0) { Version = P_0; } } } namespace BoneHub { public sealed class BoneHubService : IDisposable { private readonly IModIoClient _client; private readonly DownloadManager _downloads; private readonly BoneHubOptions _options; private readonly SearchCache _cache; private readonly JsonFileStore _stateStore; private readonly SemaphoreSlim _stateGate = new SemaphoreSlim(1, 1); private BoneHubState _state = new BoneHubState(); public int FavoriteCount => _state.FavoriteModIds.Count; public IReadOnlyCollection Installed => (IReadOnlyCollection)(object)_state.InstalledMods.Values.ToArray(); public BoneHubPreferences Preferences => _state.Preferences; public IReadOnlyList RecentlyEquippedAvatarBarcodes => _state.RecentlyEquippedAvatarBarcodes.ToArray(); public string LastEquippedAvatarBarcode { get { if (!string.IsNullOrWhiteSpace(_state.LastEquippedAvatarBarcode)) { return _state.LastEquippedAvatarBarcode; } return _state.RecentlyEquippedAvatarBarcodes.FirstOrDefault() ?? string.Empty; } } public TrashedModRecord? LastTrashed => _state.TrashHistory.LastOrDefault(); public IReadOnlyCollection PendingDeletionModIds => (IReadOnlyCollection)(object)_state.PendingDeletions.Select((PendingDeletionRecord item) => item.Mod.ModId).ToArray(); public bool ModIoReady => _options.CanUseModIo; public string ApiKeyFilePath => _options.ApiKeyFilePath; public event Action? DownloadChanged; public event Action? InstalledChanged; public BoneHubService(IModIoClient client, DownloadManager downloads, BoneHubOptions options) { _client = client; _downloads = downloads; _options = options; _cache = new SearchCache(Path.Combine(options.DataDirectory, "cache", "search")); _stateStore = new JsonFileStore(Path.Combine(options.DataDirectory, "state.json")); _downloads.JobChanged += OnJobChanged; } public async Task InitializeAsync(CancellationToken cancellationToken = default(CancellationToken)) { _state = await _stateStore.LoadAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } public async Task> SearchAsync(ModSearchRequest request, CancellationToken cancellationToken = default(CancellationToken)) { ModSearchRequest normalized = request with { Query = request.Query.Trim(), Limit = Math.Clamp(request.Limit, 1, 100), Offset = Math.Max(request.Offset, 0) }; string key = $"{normalized.Query}|{normalized.Tag}|{normalized.Creator}|{normalized.Sort}|{normalized.Offset}|{normalized.Limit}|{_options.TargetPlatform}"; ModIoPage modIoPage = await _cache.ReadAsync(key, TimeSpan.FromMinutes(_options.CacheMinutes), cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (modIoPage != null) { return modIoPage; } try { ModIoPage page = await _client.SearchAsync(normalized, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); await _cache.WriteAsync(key, page, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); return page; } catch when (!cancellationToken.IsCancellationRequested) { ModIoPage modIoPage2 = await _cache.ReadAsync(key, TimeSpan.FromDays(7.0), cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (modIoPage2 != null) { return modIoPage2; } throw; } } public DownloadJob Install(ModIoMod mod, CancellationToken cancellationToken = default(CancellationToken)) { return _downloads.Enqueue(mod, cancellationToken); } public async Task GetInstallPlanAsync(ModIoMod root, CancellationToken cancellationToken = default(CancellationToken)) { if (!ModIoReady) { throw new InvalidOperationException("WristHub's online avatar service is unavailable."); } List ordered = new List(); HashSet installed = new HashSet(); HashSet visited = new HashSet(); HashSet visiting = new HashSet(); await VisitAsync(root, isRoot: true).ConfigureAwait(continueOnCapturedContext: false); return new ModInstallPlan(root, ordered, installed.OrderBy((long id) => id).ToArray()); async Task VisitAsync(ModIoMod mod, bool isRoot) { cancellationToken.ThrowIfCancellationRequested(); if (!visited.Contains(mod.Id)) { if (!visiting.Add(mod.Id)) { throw new InvalidDataException($"mod.io reported a dependency cycle involving mod {mod.Id}."); } if (visited.Count + visiting.Count > 32) { throw new InvalidDataException("This dependency graph exceeds WristHub's 32-mod safety limit."); } foreach (ModIoDependency item in (await _client.GetDependenciesAsync(mod.Id, cancellationToken).ConfigureAwait(continueOnCapturedContext: false)).Data.Where((ModIoDependency item) => item.ModId > 0).DistinctBy((ModIoDependency item) => item.ModId)) { if (_state.InstalledMods.ContainsKey(item.ModId)) { installed.Add(item.ModId); } else { await VisitAsync(await _client.GetModAsync(item.ModId, cancellationToken).ConfigureAwait(continueOnCapturedContext: false), isRoot: false).ConfigureAwait(continueOnCapturedContext: false); } } visiting.Remove(mod.Id); visited.Add(mod.Id); if (isRoot || !_state.InstalledMods.ContainsKey(mod.Id)) { ordered.Add(mod); } else { installed.Add(mod.Id); } } } } public async Task> InstallWithDependenciesAsync(ModIoMod root, CancellationToken cancellationToken = default(CancellationToken)) { return (await GetInstallPlanAsync(root, cancellationToken).ConfigureAwait(continueOnCapturedContext: false)).OrderedMods.Select((ModIoMod mod) => Install(mod, cancellationToken)).ToArray(); } public bool CancelDownload(Guid jobId) { return _downloads.Cancel(jobId); } public bool IsFavorite(long modId) { return _state.FavoriteModIds.Contains(modId); } public bool IsInstalled(long modId) { return _state.InstalledMods.ContainsKey(modId); } public async Task ConfigureApiKeyAsync(string value, CancellationToken cancellationToken = default(CancellationToken)) { string normalized = value?.Trim() ?? string.Empty; if (!BoneHubOptions.IsValidApiKey(normalized)) { throw new InvalidDataException("The mod.io API key must contain exactly 32 letters or numbers."); } if (string.IsNullOrWhiteSpace(_options.ApiKeyFilePath)) { throw new InvalidOperationException("WristHub's API-key file path is unavailable."); } string path = Path.GetFullPath(_options.ApiKeyFilePath); Directory.CreateDirectory(Path.GetDirectoryName(path) ?? throw new InvalidOperationException("The API-key file has no parent directory.")); string temporary = path + ".tmp"; await File.WriteAllTextAsync(temporary, normalized, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); File.Move(temporary, path, overwrite: true); _options.ApiKey = normalized; } public async Task SetFavoriteAsync(long modId, bool favorite, CancellationToken cancellationToken = default(CancellationToken)) { await _stateGate.WaitAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false); try { if (favorite) { _state.FavoriteModIds.Add(modId); } else { _state.FavoriteModIds.Remove(modId); } await _stateStore.SaveAsync(_state, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } finally { _stateGate.Release(); } } public async Task> GetFavoriteModsAsync(CancellationToken cancellationToken = default(CancellationToken)) { await _stateGate.WaitAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false); long[] array; try { array = _state.FavoriteModIds.Take(50).ToArray(); } finally { _stateGate.Release(); } List results = new List(); long[] array2 = array; foreach (long modId in array2) { cancellationToken.ThrowIfCancellationRequested(); try { List list = results; list.Add(await _client.GetModAsync(modId, cancellationToken).ConfigureAwait(continueOnCapturedContext: false)); } catch when (!cancellationToken.IsCancellationRequested) { } } return results.OrderBy((ModIoMod mod) => mod.Name).ToArray(); } public async Task MarkViewedAsync(long modId, CancellationToken cancellationToken = default(CancellationToken)) { await _stateGate.WaitAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false); try { _state.RecentlyViewedModIds.Remove(modId); _state.RecentlyViewedModIds.Insert(0, modId); if (_state.RecentlyViewedModIds.Count > 50) { _state.RecentlyViewedModIds.RemoveRange(50, _state.RecentlyViewedModIds.Count - 50); } await _stateStore.SaveAsync(_state, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } finally { _stateGate.Release(); } } public async Task MarkAvatarEquippedAsync(string barcode, CancellationToken cancellationToken = default(CancellationToken)) { string normalized = barcode?.Trim() ?? string.Empty; if (normalized.Length == 0) { return; } await _stateGate.WaitAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false); try { _state.RecentlyEquippedAvatarBarcodes.RemoveAll((string item) => string.Equals(item, normalized, StringComparison.OrdinalIgnoreCase)); _state.RecentlyEquippedAvatarBarcodes.Insert(0, normalized); _state.LastEquippedAvatarBarcode = normalized; if (_state.RecentlyEquippedAvatarBarcodes.Count > 50) { _state.RecentlyEquippedAvatarBarcodes.RemoveRange(50, _state.RecentlyEquippedAvatarBarcodes.Count - 50); } await _stateStore.SaveAsync(_state, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } finally { _stateGate.Release(); } } public async Task ForgetAvatarBarcodesAsync(IEnumerable barcodes, CancellationToken cancellationToken = default(CancellationToken)) { HashSet removed = (from item in barcodes where !string.IsNullOrWhiteSpace(item) select item.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase); if (removed.Count == 0) { return; } await _stateGate.WaitAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false); try { _state.RecentlyEquippedAvatarBarcodes.RemoveAll(removed.Contains); if (removed.Contains(_state.LastEquippedAvatarBarcode)) { _state.LastEquippedAvatarBarcode = _state.RecentlyEquippedAvatarBarcodes.FirstOrDefault() ?? string.Empty; } await _stateStore.SaveAsync(_state, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } finally { _stateGate.Release(); } } public async Task SavePreferencesAsync(CancellationToken cancellationToken = default(CancellationToken)) { await _stateGate.WaitAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false); try { if (!Enum.IsDefined(_state.Preferences.ComfortPreset)) { _state.Preferences.ComfortPreset = BoneHubComfortPreset.Default; } if (!Enum.IsDefined(_state.Preferences.AvatarBrowserStartupMode)) { _state.Preferences.AvatarBrowserStartupMode = BrowserStartupMode.AllInstalled; } if (!Enum.IsDefined(_state.Preferences.LastAvatarBrowseScope)) { _state.Preferences.LastAvatarBrowseScope = AvatarBrowseScope.AllInstalled; } _state.Preferences.WatchScale = Math.Clamp(_state.Preferences.WatchScale, 0.65f, 1.6f); _state.Preferences.DwellSeconds = Math.Clamp(_state.Preferences.DwellSeconds, 1.5f, 1.5f); _state.Preferences.OutwardOffset = Math.Clamp(_state.Preferences.OutwardOffset, 0.015f, 0.06f); _state.Preferences.FingerOffset = Math.Clamp(_state.Preferences.FingerOffset, -0.055f, 0.025f); _state.Preferences.CarouselDistance = Math.Clamp(_state.Preferences.CarouselDistance, 0.45f, 0.95f); _state.Preferences.HologramScale = Math.Clamp(_state.Preferences.HologramScale, 0.7f, 1.4f); _state.Preferences.EquipZoneScale = Math.Clamp(_state.Preferences.EquipZoneScale, 0.7f, 1.6f); _state.Preferences.HologramBrightness = Math.Clamp(_state.Preferences.HologramBrightness, 0.35f, 1.5f); await _stateStore.SaveAsync(_state, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } finally { _stateGate.Release(); } } public InstalledModHealth VerifyInstalled(long modId) { if (!_state.InstalledMods.TryGetValue(modId, out BoneHub.Persistence.InstalledModRecord value)) { return new InstalledModHealth(Healthy: false, new string[1] { "WristHub has no installation record for this mod." }); } List list = new List(); foreach (string installedDirectory in value.InstalledDirectories) { string text; try { text = ValidateInstalledPath(installedDirectory); } catch (Exception ex) { list.Add(ex.Message); continue; } if (!Directory.Exists(text)) { list.Add("Missing folder: " + Path.GetFileName(text)); } else if (!PalletManifestLocator.HasManifest(text)) { list.Add("Missing pallet manifest: " + Path.GetFileName(text)); } } return new InstalledModHealth(list.Count == 0, list); } public async Task CheckForUpdateAsync(long modId, CancellationToken cancellationToken = default(CancellationToken)) { if (!_state.InstalledMods.TryGetValue(modId, out BoneHub.Persistence.InstalledModRecord installed)) { throw new InvalidOperationException("This mod is not installed."); } ModIoMod mod = await _client.GetModAsync(modId, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); ModIoFile modIoFile = await ModIoFileResolver.ResolveAsync(_client, mod, _options, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); return new ModUpdateInfo(mod, modIoFile.Id != installed.FileId, installed.FileId, modIoFile.Id); } public async Task> CheckAllUpdatesAsync(CancellationToken cancellationToken = default(CancellationToken)) { List results = new List(); foreach (BoneHub.Persistence.InstalledModRecord item in _state.InstalledMods.Values.OrderBy((BoneHub.Persistence.InstalledModRecord item) => item.Name)) { cancellationToken.ThrowIfCancellationRequested(); List list = results; list.Add(await CheckForUpdateAsync(item.ModId, cancellationToken).ConfigureAwait(continueOnCapturedContext: false)); } return results; } public async Task RepairOrUpdateAsync(long modId, CancellationToken cancellationToken = default(CancellationToken)) { return Install((await CheckForUpdateAsync(modId, cancellationToken).ConfigureAwait(continueOnCapturedContext: false)).Mod, cancellationToken); } public async Task RepairSharingRegistrationsAsync(CancellationToken cancellationToken = default(CancellationToken)) { BoneHub.Persistence.InstalledModRecord[] array = _state.InstalledMods.Values.Where((BoneHub.Persistence.InstalledModRecord installedModRecord) => installedModRecord.ModId > 0 && installedModRecord.InstalledDirectories.Any(Directory.Exists) && (installedModRecord.Sharing == null || installedModRecord.Sharing.RegistrationVersion < 3 || installedModRecord.Sharing.SidecarManifestPaths.Count == 0 || installedModRecord.Sharing.SidecarManifestPaths.Any((string path) => !File.Exists(path)))).ToArray(); int repaired = 0; BoneHub.Persistence.InstalledModRecord[] array2 = array; foreach (BoneHub.Persistence.InstalledModRecord record in array2) { cancellationToken.ThrowIfCancellationRequested(); try { ModIoMod mod = await _client.GetModAsync(record.ModId, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); ModIoFile modIoFile = ((record.FileId <= 0) ? (await ModIoFileResolver.ResolveAsync(_client, mod, _options, cancellationToken).ConfigureAwait(continueOnCapturedContext: false)) : (await _client.GetFileAsync(record.ModId, record.FileId, cancellationToken).ConfigureAwait(continueOnCapturedContext: false))); ModIoFile file = modIoFile; ModIoSharingMetadata sharing = await NetworkerRegistrationWriter.WriteAsync(mod, file, await ModIoFileResolver.ResolvePlatformFilesAsync(_client, record.ModId, cancellationToken).ConfigureAwait(continueOnCapturedContext: false), record.InstalledDirectories, _options.ModsDirectory, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); await _stateGate.WaitAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false); try { if (_state.InstalledMods.TryGetValue(record.ModId, out BoneHub.Persistence.InstalledModRecord value)) { value.Sharing = sharing; } await _stateStore.SaveAsync(_state, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } finally { _stateGate.Release(); } repaired++; } catch when (!cancellationToken.IsCancellationRequested) { } } if (repaired > 0) { this.InstalledChanged?.Invoke(); } return repaired; } public Task UninstallAsync(long modId, CancellationToken cancellationToken = default(CancellationToken)) { return UninstallCoreAsync(modId, null, null, wasSubscribed: false, cancellationToken); } public Task UninstallAsync(long modId, string? localName, IReadOnlyList? localDirectories, CancellationToken cancellationToken = default(CancellationToken)) { return UninstallCoreAsync(modId, localName, localDirectories, wasSubscribed: false, cancellationToken); } public Task UninstallSubscribedAsync(long modId, string? localName, IReadOnlyList? localDirectories, bool wasSubscribed, CancellationToken cancellationToken = default(CancellationToken)) { return UninstallCoreAsync(modId, localName, localDirectories, wasSubscribed, cancellationToken); } public async Task QueueDeletionAsync(long modId, string? localName, IReadOnlyList? localDirectories, bool wasSubscribed, IEnumerable? avatarBarcodes, CancellationToken cancellationToken = default(CancellationToken)) { await _stateGate.WaitAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false); PendingDeletionRecord queued; try { PendingDeletionRecord pendingDeletionRecord = _state.PendingDeletions.FirstOrDefault((PendingDeletionRecord item) => item.Mod.ModId == modId); if (pendingDeletionRecord != null) { return pendingDeletionRecord; } _state.InstalledMods.TryGetValue(modId, out BoneHub.Persistence.InstalledModRecord value); IEnumerable enumerable = localDirectories; object obj = enumerable; if (obj == null) { enumerable = value?.InstalledDirectories; obj = enumerable ?? Array.Empty(); } List list = ((IEnumerable)obj).Where((string path) => !string.IsNullOrWhiteSpace(path)).Select(ValidateInstalledPath).Where(Directory.Exists) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (list.Count == 0) { throw new DirectoryNotFoundException("No installed avatar-pack folders were found to queue."); } if (value == null) { value = new BoneHub.Persistence.InstalledModRecord { ModId = modId, Name = (string.IsNullOrWhiteSpace(localName) ? "Local avatar pack" : localName.Trim()), InstalledDirectories = list, InstalledAt = DateTimeOffset.UtcNow }; } List sidecarFiles = (from path in (value.Sharing?.SidecarManifestPaths ?? new List()).Concat(FindRegistrationSidecars(list)) where !string.IsNullOrWhiteSpace(path) select path).Select(ValidateInstalledFile).Where(File.Exists).Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); List barcodes = (from text in avatarBarcodes ?? Array.Empty() where !string.IsNullOrWhiteSpace(text) select text.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); queued = new PendingDeletionRecord { Mod = value, Directories = list, SidecarFiles = sidecarFiles, AvatarBarcodes = barcodes, WasSubscribed = wasSubscribed }; _state.PendingDeletions.Add(queued); _state.InstalledMods.Remove(modId); _state.RecentlyEquippedAvatarBarcodes.RemoveAll((string value2) => barcodes.Contains(value2, StringComparer.OrdinalIgnoreCase)); if (barcodes.Contains(_state.LastEquippedAvatarBarcode, StringComparer.OrdinalIgnoreCase)) { _state.LastEquippedAvatarBarcode = _state.RecentlyEquippedAvatarBarcodes.FirstOrDefault() ?? string.Empty; } await _stateStore.SaveAsync(_state, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } finally { _stateGate.Release(); } this.InstalledChanged?.Invoke(); return queued; } public async Task ProcessPendingDeletionsAsync(CancellationToken cancellationToken = default(CancellationToken)) { int completed = 0; List problems = new List(); await _stateGate.WaitAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false); try { PendingDeletionRecord[] array = _state.PendingDeletions.ToArray(); foreach (PendingDeletionRecord pending in array) { cancellationToken.ThrowIfCancellationRequested(); string text = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(_options.ModsDirectory).TrimEnd(Path.DirectorySeparatorChar)) ?? throw new InvalidOperationException("Mods directory has no parent."), ".wristhub-trash", $"{pending.Mod.ModId}-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"); Directory.CreateDirectory(text); List list = new List(); List list2 = new List(); try { for (int j = 0; j < pending.Directories.Count; j++) { string text2 = ValidateInstalledPath(pending.Directories[j]); if (Directory.Exists(text2)) { string text3 = Path.Combine(text, $"{j:D2}-{Path.GetFileName(text2)}"); Directory.Move(text2, text3); list.Add(new TrashedDirectoryRecord { OriginalPath = text2, TrashPath = text3 }); } } for (int k = 0; k < pending.SidecarFiles.Count; k++) { string text4 = ValidateInstalledFile(pending.SidecarFiles[k]); if (File.Exists(text4)) { string text5 = Path.Combine(text, $"manifest-{k:D2}-{Path.GetFileName(text4)}"); File.Move(text4, text5); list2.Add(new TrashedFileRecord { OriginalPath = text4, TrashPath = text5 }); } } if (list.Count > 0 || list2.Count > 0) { _state.TrashHistory.Add(new TrashedModRecord { Mod = pending.Mod, Directories = list, Files = list2, WasSubscribed = pending.WasSubscribed }); if (_state.TrashHistory.Count > 20) { _state.TrashHistory.RemoveRange(0, _state.TrashHistory.Count - 20); } } else if (Directory.Exists(text)) { Directory.Delete(text, recursive: false); } _state.PendingDeletions.Remove(pending); _state.InstalledMods.Remove(pending.Mod.ModId); _state.RecentlyEquippedAvatarBarcodes.RemoveAll((string value) => pending.AvatarBarcodes.Contains(value, StringComparer.OrdinalIgnoreCase)); if (pending.AvatarBarcodes.Contains(_state.LastEquippedAvatarBarcode, StringComparer.OrdinalIgnoreCase)) { _state.LastEquippedAvatarBarcode = _state.RecentlyEquippedAvatarBarcodes.FirstOrDefault() ?? string.Empty; } completed++; } catch (Exception ex) { for (int num = list.Count - 1; num >= 0; num--) { TrashedDirectoryRecord trashedDirectoryRecord = list[num]; if (Directory.Exists(trashedDirectoryRecord.TrashPath) && !Directory.Exists(trashedDirectoryRecord.OriginalPath)) { Directory.Move(trashedDirectoryRecord.TrashPath, trashedDirectoryRecord.OriginalPath); } } for (int num2 = list2.Count - 1; num2 >= 0; num2--) { TrashedFileRecord trashedFileRecord = list2[num2]; if (File.Exists(trashedFileRecord.TrashPath) && !File.Exists(trashedFileRecord.OriginalPath)) { File.Move(trashedFileRecord.TrashPath, trashedFileRecord.OriginalPath); } } try { if (Directory.Exists(text) && !Directory.EnumerateFileSystemEntries(text).Any()) { Directory.Delete(text); } } catch { } problems.Add(pending.Mod.Name + ": " + ex.Message); } } await _stateStore.SaveAsync(_state, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } finally { _stateGate.Release(); } if (completed > 0) { this.InstalledChanged?.Invoke(); } return new PendingDeletionProcessResult(completed, _state.PendingDeletions.Count, problems); } private async Task UninstallCoreAsync(long modId, string? localName, IReadOnlyList? localDirectories, bool wasSubscribed, CancellationToken cancellationToken) { await _stateGate.WaitAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false); try { BoneHub.Persistence.InstalledModRecord installed; bool wasTracked = _state.InstalledMods.TryGetValue(modId, out installed); if (!wasTracked) { List list = (localDirectories ?? Array.Empty()).Where((string path) => !string.IsNullOrWhiteSpace(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); if (list.Count == 0) { throw new InvalidOperationException("WristHub could not identify this avatar pack's local folder."); } installed = new BoneHub.Persistence.InstalledModRecord { ModId = modId, Name = (string.IsNullOrWhiteSpace(localName) ? "Local avatar pack" : localName.Trim()), InstalledDirectories = list, InstalledAt = DateTimeOffset.UtcNow }; } BoneHub.Persistence.InstalledModRecord installedModRecord = installed ?? throw new InvalidOperationException("The avatar pack record is unavailable."); string text = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(_options.ModsDirectory).TrimEnd(Path.DirectorySeparatorChar)) ?? throw new InvalidOperationException("Mods directory has no parent."), ".wristhub-trash", $"{modId}-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"); Directory.CreateDirectory(text); List moved = new List(); List movedFiles = new List(); TrashedModRecord stateRecord = null; try { for (int num = 0; num < installedModRecord.InstalledDirectories.Count; num++) { cancellationToken.ThrowIfCancellationRequested(); string text2 = ValidateInstalledPath(installedModRecord.InstalledDirectories[num]); if (Directory.Exists(text2)) { string text3 = Path.Combine(text, $"{num:D2}-{Path.GetFileName(text2)}"); Directory.Move(text2, text3); moved.Add(new TrashedDirectoryRecord { OriginalPath = text2, TrashPath = text3 }); } } string[] array = (installedModRecord.Sharing?.SidecarManifestPaths ?? new List()).Concat(FindRegistrationSidecars(installedModRecord.InstalledDirectories)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); for (int num2 = 0; num2 < array.Length; num2++) { cancellationToken.ThrowIfCancellationRequested(); string text4 = ValidateInstalledFile(array[num2]); if (File.Exists(text4)) { string text5 = Path.Combine(text, $"manifest-{num2:D2}-{Path.GetFileName(text4)}"); File.Move(text4, text5); movedFiles.Add(new TrashedFileRecord { OriginalPath = text4, TrashPath = text5 }); } } if (moved.Count == 0) { throw new DirectoryNotFoundException("No installed avatar-pack folders were found to remove."); } TrashedModRecord record = new TrashedModRecord { Mod = installedModRecord, Directories = moved, Files = movedFiles, WasSubscribed = wasSubscribed }; stateRecord = record; _state.InstalledMods.Remove(modId); _state.TrashHistory.Add(record); if (_state.TrashHistory.Count > 20) { _state.TrashHistory.RemoveRange(0, _state.TrashHistory.Count - 20); } await _stateStore.SaveAsync(_state, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); this.InstalledChanged?.Invoke(); return record; } catch { if (wasTracked) { _state.InstalledMods[modId] = installed; } else { _state.InstalledMods.Remove(modId); } if (stateRecord != null) { _state.TrashHistory.Remove(stateRecord); } for (int num3 = moved.Count - 1; num3 >= 0; num3--) { TrashedDirectoryRecord trashedDirectoryRecord = moved[num3]; if (Directory.Exists(trashedDirectoryRecord.TrashPath) && !Directory.Exists(trashedDirectoryRecord.OriginalPath)) { Directory.Move(trashedDirectoryRecord.TrashPath, trashedDirectoryRecord.OriginalPath); } } for (int num4 = movedFiles.Count - 1; num4 >= 0; num4--) { TrashedFileRecord trashedFileRecord = movedFiles[num4]; if (File.Exists(trashedFileRecord.TrashPath) && !File.Exists(trashedFileRecord.OriginalPath)) { File.Move(trashedFileRecord.TrashPath, trashedFileRecord.OriginalPath); } } throw; } } finally { _stateGate.Release(); } } public async Task UndoLastUninstallAsync(CancellationToken cancellationToken = default(CancellationToken)) { await _stateGate.WaitAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false); try { TrashedModRecord record = _state.TrashHistory.LastOrDefault() ?? throw new InvalidOperationException("There is no WristHub uninstall to undo."); if (_state.InstalledMods.ContainsKey(record.Mod.ModId)) { throw new InvalidOperationException("That mod is already installed again."); } List restored = new List(); List restoredFiles = new List(); try { foreach (TrashedDirectoryRecord directory in record.Directories) { cancellationToken.ThrowIfCancellationRequested(); string text = ValidateInstalledPath(directory.OriginalPath); string text2 = ValidateTrashPath(directory.TrashPath); if (Directory.Exists(text)) { throw new IOException("Cannot restore " + Path.GetFileName(text) + " because that folder already exists."); } if (!Directory.Exists(text2)) { throw new DirectoryNotFoundException("Trash data is missing for " + record.Mod.Name + "."); } Directory.Move(text2, text); restored.Add(new TrashedDirectoryRecord { OriginalPath = text, TrashPath = text2 }); } foreach (TrashedFileRecord file in record.Files) { cancellationToken.ThrowIfCancellationRequested(); string text3 = ValidateInstalledFile(file.OriginalPath); string text4 = ValidateTrashPath(file.TrashPath); if (File.Exists(text3)) { throw new IOException("Cannot restore " + Path.GetFileName(text3) + " because it already exists."); } if (!File.Exists(text4)) { throw new FileNotFoundException("Trash sidecar data is missing.", text4); } File.Move(text4, text3); restoredFiles.Add(new TrashedFileRecord { OriginalPath = text3, TrashPath = text4 }); } _state.InstalledMods[record.Mod.ModId] = record.Mod; _state.TrashHistory.Remove(record); await _stateStore.SaveAsync(_state, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); this.InstalledChanged?.Invoke(); return record.Mod; } catch { _state.InstalledMods.Remove(record.Mod.ModId); if (!_state.TrashHistory.Contains(record)) { _state.TrashHistory.Add(record); } for (int num = restored.Count - 1; num >= 0; num--) { TrashedDirectoryRecord trashedDirectoryRecord = restored[num]; if (Directory.Exists(trashedDirectoryRecord.OriginalPath) && !Directory.Exists(trashedDirectoryRecord.TrashPath)) { Directory.Move(trashedDirectoryRecord.OriginalPath, trashedDirectoryRecord.TrashPath); } } for (int num2 = restoredFiles.Count - 1; num2 >= 0; num2--) { TrashedFileRecord trashedFileRecord = restoredFiles[num2]; if (File.Exists(trashedFileRecord.OriginalPath) && !File.Exists(trashedFileRecord.TrashPath)) { File.Move(trashedFileRecord.OriginalPath, trashedFileRecord.TrashPath); } } throw; } } finally { _stateGate.Release(); } } private void OnJobChanged(DownloadJob job) { if (job.State == DownloadState.Completed && job.Result != null) { PersistCompletedJobAsync(job, job.Result); } else { this.DownloadChanged?.Invoke(job); } } private async Task PersistCompletedJobAsync(DownloadJob job, BoneHub.Downloads.InstalledModRecord result) { await _stateGate.WaitAsync().ConfigureAwait(continueOnCapturedContext: false); try { _state.InstalledMods[result.ModId] = new BoneHub.Persistence.InstalledModRecord { ModId = result.ModId, FileId = result.FileId, Name = result.Name, Version = result.Version, Md5 = result.Md5, InstalledDirectories = result.InstalledDirectories.ToList(), Sharing = result.Sharing, InstalledAt = DateTimeOffset.UtcNow }; await _stateStore.SaveAsync(_state).ConfigureAwait(continueOnCapturedContext: false); this.DownloadChanged?.Invoke(job); this.InstalledChanged?.Invoke(); } finally { _stateGate.Release(); } } private string ValidateInstalledPath(string path) { string text = Path.GetFullPath(_options.ModsDirectory).TrimEnd(Path.DirectorySeparatorChar); string text2 = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar); if (!text2.StartsWith(text + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || string.Equals(text2, text, StringComparison.OrdinalIgnoreCase)) { throw new InvalidDataException("An installed-mod path falls outside BONELAB's Mods directory."); } return text2; } private string ValidateInstalledFile(string path) { string text = Path.GetFullPath(_options.ModsDirectory).TrimEnd(Path.DirectorySeparatorChar); string fullPath = Path.GetFullPath(path); if (!fullPath.StartsWith(text + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || !string.Equals(Path.GetExtension(fullPath), ".manifest", StringComparison.OrdinalIgnoreCase)) { throw new InvalidDataException("A mod registration sidecar falls outside BONELAB's Mods directory."); } return fullPath; } private IReadOnlyList FindRegistrationSidecars(IEnumerable packageDirectories) { string[] source = packageDirectories.Select(Path.GetFullPath).ToArray(); List list = new List(); if (!Directory.Exists(_options.ModsDirectory)) { return list; } foreach (string item in Directory.EnumerateFiles(_options.ModsDirectory, "*.manifest", SearchOption.TopDirectoryOnly)) { try { using JsonDocument jsonDocument = JsonDocument.Parse(File.ReadAllText(item)); JsonElement rootElement = jsonDocument.RootElement; string propertyName = rootElement.GetProperty("root").GetProperty("ref").GetString() ?? "1"; string text = rootElement.GetProperty("objects").GetProperty(propertyName).GetProperty("palletPath") .GetString(); if (!string.IsNullOrWhiteSpace(text)) { string fullPallet = Path.GetFullPath(text); if (source.Any((string package) => fullPallet.StartsWith(package.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))) { list.Add(item); } } } catch { } } return list; } private string ValidateTrashPath(string path) { string? path2 = Path.GetDirectoryName(Path.GetFullPath(_options.ModsDirectory).TrimEnd(Path.DirectorySeparatorChar)) ?? throw new InvalidOperationException("Mods directory has no parent."); string text = Path.GetFullPath(Path.Combine(path2, ".wristhub-trash")).TrimEnd(Path.DirectorySeparatorChar); string text2 = Path.GetFullPath(Path.Combine(path2, ".bonehub-trash")).TrimEnd(Path.DirectorySeparatorChar); string text3 = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar); bool num = text3.StartsWith(text + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) && !string.Equals(text3, text, StringComparison.OrdinalIgnoreCase); bool flag = text3.StartsWith(text2 + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) && !string.Equals(text3, text2, StringComparison.OrdinalIgnoreCase); if (!num && !flag) { throw new InvalidDataException("A restore path falls outside WristHub Trash."); } return text3; } public void Dispose() { _downloads.JobChanged -= OnJobChanged; _downloads.Dispose(); (_client as IDisposable)?.Dispose(); _stateGate.Dispose(); } } } namespace BoneHub.Persistence { public sealed class BoneHubState { public HashSet FavoriteModIds { get; init; } = new HashSet(); public List RecentlyViewedModIds { get; init; } = new List(); public List RecentlyEquippedAvatarBarcodes { get; init; } = new List(); public string LastEquippedAvatarBarcode { get; set; } = string.Empty; public Dictionary InstalledMods { get; init; } = new Dictionary(); public BoneHubPreferences Preferences { get; init; } = new BoneHubPreferences(); public List TrashHistory { get; init; } = new List(); public List PendingDeletions { get; init; } = new List(); } public sealed class BoneHubPreferences { public BoneHubComfortPreset ComfortPreset { get; set; } public bool WatchEnabled { get; set; } = true; public bool RaiseToOpen { get; set; } = true; public bool UseLeftWrist { get; set; } = true; public float WatchScale { get; set; } = 1f; public float DwellSeconds { get; set; } = 1.5f; public float OutwardOffset { get; set; } = 0.029f; public float FingerOffset { get; set; } = -0.018f; public bool ReducedMotion { get; set; } public bool HologramEffects { get; set; } = true; public bool HapticsEnabled { get; set; } = true; public bool SoundsEnabled { get; set; } = true; public float CarouselDistance { get; set; } = 0.62f; public float HologramScale { get; set; } = 1f; public float EquipZoneScale { get; set; } = 1f; public float HologramBrightness { get; set; } = 1f; public BrowserStartupMode AvatarBrowserStartupMode { get; set; } = BrowserStartupMode.AllInstalled; public AvatarBrowseScope LastAvatarBrowseScope { get; set; } } public enum BoneHubComfortPreset { Default, Seated, LargeTargets, ReducedMotion } public sealed class InstalledModRecord { public long ModId { get; init; } public long FileId { get; init; } public string Name { get; init; } = string.Empty; public string Version { get; init; } = string.Empty; public string Md5 { get; init; } = string.Empty; public List InstalledDirectories { get; init; } = new List(); public ModIoSharingMetadata? Sharing { get; set; } public DateTimeOffset InstalledAt { get; init; } } public sealed class TrashedModRecord { public Guid Id { get; init; } = Guid.NewGuid(); public InstalledModRecord Mod { get; init; } = new InstalledModRecord(); public List Directories { get; init; } = new List(); public List Files { get; init; } = new List(); public bool WasSubscribed { get; init; } public DateTimeOffset TrashedAt { get; init; } = DateTimeOffset.UtcNow; } public sealed class TrashedFileRecord { public string OriginalPath { get; init; } = string.Empty; public string TrashPath { get; init; } = string.Empty; } public sealed class TrashedDirectoryRecord { public string OriginalPath { get; init; } = string.Empty; public string TrashPath { get; init; } = string.Empty; } public sealed class PendingDeletionRecord { public InstalledModRecord Mod { get; init; } = new InstalledModRecord(); public List Directories { get; init; } = new List(); public List SidecarFiles { get; init; } = new List(); public List AvatarBarcodes { get; init; } = new List(); public bool WasSubscribed { get; init; } public DateTimeOffset QueuedAt { get; init; } = DateTimeOffset.UtcNow; } public sealed record PendingDeletionProcessResult(int Completed, int Remaining, IReadOnlyList Problems); public sealed class JsonFileStore where T : class, new() { private readonly string _path; private readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1); private readonly JsonSerializerOptions _json = new JsonSerializerOptions { WriteIndented = true, PropertyNameCaseInsensitive = true }; public JsonFileStore(string path) { _path = Path.GetFullPath(path); } public async Task LoadAsync(CancellationToken cancellationToken = default(CancellationToken)) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false); try { if (!File.Exists(_path)) { return new T(); } T result; await using (FileStream stream = File.OpenRead(_path)) { result = (await JsonSerializer.DeserializeAsync(stream, _json, cancellationToken).ConfigureAwait(continueOnCapturedContext: false)) ?? new T(); } return result; } catch (JsonException) { string destFileName = _path + $".corrupt-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}"; File.Move(_path, destFileName, overwrite: false); return new T(); } finally { _gate.Release(); } } public async Task SaveAsync(T value, CancellationToken cancellationToken = default(CancellationToken)) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false); try { Directory.CreateDirectory(Path.GetDirectoryName(_path)); string temporary = _path + ".tmp"; await using (FileStream stream = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None)) { await JsonSerializer.SerializeAsync(stream, value, _json, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); await stream.FlushAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } File.Move(temporary, _path, overwrite: true); } finally { _gate.Release(); } } } public sealed class SearchCache { public sealed class CachedSearch { public DateTimeOffset SavedAt { get; init; } public ModIoPage Page { get; init; } = new ModIoPage(); } private readonly string _directory; public SearchCache(string directory) { _directory = Path.GetFullPath(directory); } public async Task?> ReadAsync(string key, TimeSpan maximumAge, CancellationToken cancellationToken = default(CancellationToken)) { CachedSearch cachedSearch = await new JsonFileStore(PathFor(key)).LoadAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false); return (cachedSearch.SavedAt != default(DateTimeOffset) && DateTimeOffset.UtcNow - cachedSearch.SavedAt <= maximumAge) ? cachedSearch.Page : null; } public Task WriteAsync(string key, ModIoPage page, CancellationToken cancellationToken = default(CancellationToken)) { return new JsonFileStore(PathFor(key)).SaveAsync(new CachedSearch { SavedAt = DateTimeOffset.UtcNow, Page = page }, cancellationToken); } private string PathFor(string key) { Directory.CreateDirectory(_directory); byte[] inArray = SHA256.HashData(Encoding.UTF8.GetBytes(key)); return Path.Combine(_directory, Convert.ToHexString(inArray).ToLowerInvariant() + ".json"); } } } namespace BoneHub.Models { public sealed class ModIoPage { [JsonPropertyName("data")] public List Data { get; init; } = new List(); [JsonPropertyName("result_count")] public int ResultCount { get; init; } [JsonPropertyName("result_offset")] public int ResultOffset { get; init; } [JsonPropertyName("result_limit")] public int ResultLimit { get; init; } [JsonPropertyName("result_total")] public int ResultTotal { get; init; } } public sealed class ModIoMod { [JsonPropertyName("id")] public long Id { get; init; } [JsonPropertyName("name")] public string Name { get; init; } = string.Empty; [JsonPropertyName("name_id")] public string NameId { get; init; } = string.Empty; [JsonPropertyName("summary")] public string Summary { get; init; } = string.Empty; [JsonPropertyName("description_plaintext")] public string Description { get; init; } = string.Empty; [JsonPropertyName("profile_url")] public string ProfileUrl { get; init; } = string.Empty; [JsonPropertyName("date_updated")] public long DateUpdated { get; init; } [JsonPropertyName("submitted_by")] public ModIoUser Author { get; init; } = new ModIoUser(); [JsonPropertyName("logo")] public ModIoLogo Logo { get; init; } = new ModIoLogo(); [JsonPropertyName("modfile")] public ModIoFile? Modfile { get; init; } [JsonPropertyName("stats")] public ModIoStats Stats { get; init; } = new ModIoStats(); [JsonPropertyName("tags")] public List Tags { get; init; } = new List(); [JsonPropertyName("maturity_option")] public int MaturityOption { get; init; } public bool HasTag(string value) { return Tags.Any((ModIoTag tag) => string.Equals(tag.Name, value, StringComparison.OrdinalIgnoreCase)); } } public sealed class ModIoUser { [JsonPropertyName("username")] public string Username { get; init; } = string.Empty; [JsonPropertyName("display_name_portal")] public string? DisplayNamePortal { get; init; } public string DisplayName { get { if (!string.IsNullOrWhiteSpace(DisplayNamePortal)) { return DisplayNamePortal; } return Username; } } } public sealed class ModIoLogo { [JsonPropertyName("original")] public string Original { get; init; } = string.Empty; [JsonPropertyName("thumb_320x180")] public string Thumbnail { get; init; } = string.Empty; } public sealed class ModIoStats { [JsonPropertyName("downloads_total")] public long DownloadsTotal { get; init; } [JsonPropertyName("subscribers_total")] public long SubscribersTotal { get; init; } [JsonPropertyName("ratings_weighted_aggregate")] public double Rating { get; init; } } public sealed class ModIoTag { [JsonPropertyName("name")] public string Name { get; init; } = string.Empty; } public sealed class ModIoFile { [JsonPropertyName("id")] public long Id { get; init; } [JsonPropertyName("mod_id")] public long ModId { get; init; } [JsonPropertyName("filename")] public string Filename { get; init; } = string.Empty; [JsonPropertyName("version")] public string Version { get; init; } = string.Empty; [JsonPropertyName("date_added")] public long DateAdded { get; init; } [JsonPropertyName("filesize")] public long FileSize { get; init; } [JsonPropertyName("filesize_uncompressed")] public long UncompressedSize { get; init; } [JsonPropertyName("virus_status")] public int VirusStatus { get; init; } [JsonPropertyName("virus_positive")] public int VirusPositive { get; init; } [JsonPropertyName("filehash")] public ModIoFileHash Hash { get; init; } = new ModIoFileHash(); [JsonPropertyName("download")] public ModIoDownload Download { get; init; } = new ModIoDownload(); [JsonPropertyName("platforms")] public List Platforms { get; init; } = new List(); } public sealed class ModIoFileHash { [JsonPropertyName("md5")] public string Md5 { get; init; } = string.Empty; } public sealed class ModIoDownload { [JsonPropertyName("binary_url")] public string BinaryUrl { get; init; } = string.Empty; [JsonPropertyName("date_expires")] public long DateExpires { get; init; } } public sealed class ModIoPlatform { [JsonPropertyName("platform")] public string Platform { get; init; } = string.Empty; [JsonPropertyName("status")] public int Status { get; init; } } public sealed class ModIoDependency { [JsonPropertyName("mod_id")] public long ModId { get; init; } [JsonPropertyName("date_added")] public long DateAdded { get; init; } } public enum ModSort { Trending, Newest, MostDownloaded, RecentlyUpdated } public sealed record ModSearchRequest(string Query, string? Tag = null, string? Creator = null, ModSort Sort = ModSort.Trending, int Offset = 0, int Limit = 20); public sealed record ModUpdateInfo(ModIoMod Mod, bool UpdateAvailable, long InstalledFileId, long LatestFileId); public sealed record ModInstallPlan(ModIoMod Root, IReadOnlyList OrderedMods, IReadOnlyList AlreadyInstalledIds); public sealed record InstalledModHealth(bool Healthy, IReadOnlyList Problems); } namespace BoneHub.Interaction { public enum AvatarBrowseScope { AllInstalled, Groups } public enum BrowserStartupMode { RememberLast, AllInstalled, Groups } public static class AvatarBrowseStartup { public static AvatarBrowseScope Resolve(BrowserStartupMode mode, AvatarBrowseScope lastScope) { return mode switch { BrowserStartupMode.RememberLast => Enum.IsDefined(lastScope) ? lastScope : AvatarBrowseScope.AllInstalled, BrowserStartupMode.Groups => AvatarBrowseScope.Groups, _ => AvatarBrowseScope.AllInstalled, }; } } public enum AvatarBrowserViewState { PushPrompt, Hub, Library, Pack, Options, Keyboard, Searching, Result, Downloading, PreparingPreview, Error } public enum AvatarPackSort { RecentlyUsed, Name, Creator } public sealed record AvatarPackEntry(long ModId, string Name, string Creator, string ThumbnailUrl, IReadOnlyList Avatars, bool Installed, AvatarDownloadState DownloadState = AvatarDownloadState.None, double DownloadProgress = 0.0, string? Status = null) { public string Identity => "pack:" + ModId; public int AvatarCount => Avatars.Count((AvatarEntry entry) => entry.Installed && !string.IsNullOrWhiteSpace(entry.Barcode)); } public static class AvatarPackLibrary { public static IReadOnlyList Group(IEnumerable entries, IEnumerable? recentBarcodes = null, AvatarPackSort sort = AvatarPackSort.RecentlyUsed) { Dictionary recent = (recentBarcodes ?? Array.Empty()).Select((string barcode, int index) => (barcode: barcode, index: index)).ToDictionary<(string, int), string, int>(((string barcode, int index) item) => item.barcode, ((string barcode, int index) item) => item.index, StringComparer.OrdinalIgnoreCase); IEnumerable source = (from entry in entries group entry by entry.ModId).Select(delegate(IGrouping group) { AvatarEntry[] array = group.OrderBy((AvatarEntry item) => item.AvatarName, StringComparer.OrdinalIgnoreCase).ToArray(); AvatarEntry avatarEntry = array.OrderByDescending((AvatarEntry item) => item.Installed).First(); AvatarDownloadState downloadState = array.Select((AvatarEntry item) => item.DownloadState).OrderByDescending(DownloadRank).FirstOrDefault(); return new AvatarPackEntry(group.Key, avatarEntry.ModName, avatarEntry.Creator, avatarEntry.ThumbnailUrl, array, array.Any((AvatarEntry item) => item.Installed), downloadState, array.Max((AvatarEntry item) => item.DownloadProgress), array.Select((AvatarEntry item) => item.Status).FirstOrDefault((string value) => !string.IsNullOrWhiteSpace(value))); }); return sort switch { AvatarPackSort.Name => source.OrderBy((AvatarPackEntry pack) => pack.Name, StringComparer.OrdinalIgnoreCase).ToArray(), AvatarPackSort.Creator => source.OrderBy((AvatarPackEntry pack) => pack.Creator, StringComparer.OrdinalIgnoreCase).ThenBy((AvatarPackEntry pack) => pack.Name, StringComparer.OrdinalIgnoreCase).ToArray(), _ => source.OrderBy((AvatarPackEntry pack) => RecentIndex(pack, recent)).ThenBy((AvatarPackEntry pack) => pack.Name, StringComparer.OrdinalIgnoreCase).ToArray(), }; } private static int RecentIndex(AvatarPackEntry pack, IReadOnlyDictionary recent) { int[] array = (from item in pack.Avatars where recent.ContainsKey(item.Barcode) select recent[item.Barcode]).ToArray(); if (array.Length != 0) { return array.Min(); } return int.MaxValue; } private static int DownloadRank(AvatarDownloadState state) { return state switch { AvatarDownloadState.Failed => 7, AvatarDownloadState.Installing => 6, AvatarDownloadState.Downloading => 5, AvatarDownloadState.Queued => 4, AvatarDownloadState.Cancelled => 3, AvatarDownloadState.Installed => 2, _ => 1, }; } } public sealed class FourRowPageCursor { public const int RowsPerPage = 4; public int Page { get; private set; } public int Count { get; private set; } public int PageCount => Math.Max(1, (Count + 4 - 1) / 4); public string PositionText => $"{Page + 1} / {PageCount}"; public int Reset(int count) { Count = Math.Max(0, count); Page = 0; return Page; } public int Keep(int count) { Count = Math.Max(0, count); Page = Math.Clamp(Page, 0, PageCount - 1); return Page; } public int Move(int direction) { if (PageCount <= 1 || direction == 0) { return Page; } Page = (Page + Math.Sign(direction) + PageCount) % PageCount; return Page; } } public sealed class FocusedResultCursor { public int Index { get; private set; } public int Count { get; private set; } public string PositionText { get { if (Count != 0) { return $"{Index + 1} / {Count}"; } return "0 / 0"; } } public bool CanMovePrevious => Count > 1; public bool CanMoveNext => Count > 1; public int Reset(int count) { Count = Math.Max(0, count); Index = 0; return Index; } public int Keep(int count, int preferredIndex) { Count = Math.Max(0, count); Index = ((Count != 0) ? Math.Clamp(preferredIndex, 0, Count - 1) : 0); return Index; } public int Move(int direction) { if (Count <= 1 || direction == 0) { return Index; } Index = (Index + Math.Sign(direction) + Count) % Count; return Index; } } public readonly record struct TabletRect(float X, float Y, float Width, float Height) { public float Left => X - Width * 0.5f; public float Right => X + Width * 0.5f; public float Bottom => Y - Height * 0.5f; public float Top => Y + Height * 0.5f; public bool IsInside(TabletRect parent) { if (Left >= parent.Left && Right <= parent.Right && Bottom >= parent.Bottom) { return Top <= parent.Top; } return false; } public bool Overlaps(TabletRect other) { if (Left < other.Right && Right > other.Left && Bottom < other.Top) { return Top > other.Bottom; } return false; } } public static class AvatarTabletLayout { public static readonly TabletRect Tablet = new TabletRect(0f, 0f, 340f, 210f); public static readonly TabletRect Search = new TabletRect(-34f, 82f, 150f, 30f); public static readonly TabletRect Close = new TabletRect(148f, 82f, 30f, 30f); public static readonly TabletRect Artwork = new TabletRect(-66f, -4f, 124f, 128f); public static readonly TabletRect Details = new TabletRect(65f, 3f, 126f, 112f); public static readonly TabletRect Previous = new TabletRect(-151f, 14f, 26f, 54f); public static readonly TabletRect Next = new TabletRect(151f, 14f, 26f, 54f); public static readonly TabletRect Action = new TabletRect(65f, -69f, 126f, 30f); public static readonly TabletRect Trash = new TabletRect(-66f, -91f, 116f, 18f); public static readonly TabletRect Progress = new TabletRect(65f, -91f, 126f, 14f); public static IReadOnlyList ContentRects => new TabletRect[9] { Search, Close, Artwork, Details, Previous, Next, Action, Trash, Progress }; public static TabletRect PhysicalTouchRect(TabletRect rect) { return new TabletRect(rect.X, rect.Y, rect.Width * 1.1f, rect.Height * 1.2f); } } public static class AvatarPackTabletLayout { public static readonly IReadOnlyList HeaderControls = new TabletRect[5] { new TabletRect(-145f, 83f, 42f, 28f), new TabletRect(-90f, 83f, 64f, 28f), new TabletRect(-34f, 83f, 46f, 28f), new TabletRect(25f, 83f, 68f, 28f), new TabletRect(148f, 83f, 26f, 28f) }; public static readonly IReadOnlyList Rows = (from index in Enumerable.Range(0, 4) select new TabletRect(-36f, 50f - (float)index * 34f, 228f, 30f)).ToArray(); public static readonly IReadOnlyList DeleteRows = (from index in Enumerable.Range(0, 4) select new TabletRect(125f, 50f - (float)index * 34f, 52f, 30f)).ToArray(); public static readonly TabletRect Status = new TabletRect(0f, -72f, 250f, 10f); public static readonly IReadOnlyList FooterControls = new TabletRect[3] { new TabletRect(-135f, -91f, 50f, 26f), new TabletRect(0f, -91f, 90f, 24f), new TabletRect(135f, -91f, 50f, 26f) }; public static IReadOnlyList AllControls => HeaderControls.Concat(Rows).Concat(DeleteRows).Append(Status) .Concat(FooterControls) .ToArray(); } public sealed class PresentationGeneration { public int Value { get; private set; } public int Next() { return ++Value; } public bool IsCurrent(int generation) { return generation == Value; } } public sealed class SurfaceInputRearmGate { private readonly double _clearSeconds; private double _clearSince = -1.0; public bool Required { get; private set; } public SurfaceInputRearmGate(double clearSeconds = 0.1) { _clearSeconds = Math.Max(0.0, clearSeconds); } public void Require() { Required = true; _clearSince = -1.0; } public bool CanInteract(bool touchingAnyActiveControl, double nowSeconds) { if (!Required) { return true; } if (touchingAnyActiveControl) { _clearSince = -1.0; return false; } if (_clearSince < 0.0) { _clearSince = nowSeconds; return _clearSeconds <= 0.0; } if (nowSeconds - _clearSince < _clearSeconds) { return false; } Required = false; _clearSince = -1.0; return true; } } public sealed class AvatarSearchInputSession { public int Generation { get; private set; } public bool IsOpen { get; private set; } public string InitialValue { get; private set; } = string.Empty; public int Open(string? initialValue) { InitialValue = initialValue?.Trim() ?? string.Empty; IsOpen = true; return ++Generation; } public bool TrySubmit(int generation, string? value, out string query) { query = value?.Trim() ?? string.Empty; if (!IsOpen || generation != Generation) { return false; } IsOpen = false; return true; } public bool Cancel(int generation) { if (!IsOpen || generation != Generation) { return false; } IsOpen = false; return true; } } public sealed class TabletKeyboardBuffer { public const int MaximumLength = 64; public string Value { get; private set; } = string.Empty; public bool Shifted { get; private set; } public void Set(string? value) { Value = (((value ?? string.Empty).Length <= 64) ? (value ?? string.Empty) : value.Substring(0, 64)); } public bool Append(char character) { if (Value.Length >= 64 || char.IsControl(character)) { return false; } Value += (Shifted ? char.ToUpperInvariant(character) : char.ToLowerInvariant(character)); return true; } public bool AppendSpace() { if (Value.Length >= 64 || Value.EndsWith(' ')) { return false; } Value += " "; return true; } public bool Backspace() { if (Value.Length == 0) { return false; } string value = Value; Value = value.Substring(0, value.Length - 1); return true; } public void Clear() { Value = string.Empty; } public void ToggleShift() { Shifted = !Shifted; } public string VisibleValue(int maximumCharacters = 36) { int num = Math.Clamp(maximumCharacters, 4, 64); if (Value.Length > num) { string value = Value; int num2 = num - 1; int length = value.Length; int num3 = length - num2; return "…" + value.Substring(num3, length - num3); } return Value; } } public static class PreviewDragMath { public static bool IsInside(Vector3 localPoint, Vector3 halfExtents) { if (MathF.Abs(localPoint.X) <= MathF.Max(0f, halfExtents.X) && MathF.Abs(localPoint.Y) <= MathF.Max(0f, halfExtents.Y)) { return MathF.Abs(localPoint.Z) <= MathF.Max(0f, halfExtents.Z); } return false; } public static bool PinchActive(float thumbIndexDistance, bool alreadyDragging, float beginDistance = 0.03f, float releaseDistance = 0.045f) { if (float.IsFinite(thumbIndexDistance)) { return thumbIndexDistance <= (alreadyDragging ? MathF.Max(beginDistance, releaseDistance) : beginDistance); } return false; } public static bool GripActive(float gripForce, bool alreadyDragging, float beginForce = 0.62f, float releaseForce = 0.28f) { if (float.IsFinite(gripForce)) { return gripForce >= (alreadyDragging ? MathF.Min(beginForce, releaseForce) : beginForce); } return false; } public static bool IsChestRelease(Vector3 previewPosition, Vector3 chestPosition, float radius) { if (radius > 0f) { return Vector3.Distance(previewPosition, chestPosition) <= radius; } return false; } public static Vector3 LimitAnchorOffset(Vector3 offset, float maximumDistance) { if (!float.IsFinite(offset.X) || !float.IsFinite(offset.Y) || !float.IsFinite(offset.Z)) { return Vector3.Zero; } float num = Math.Clamp(maximumDistance, 0.01f, 0.2f); float num2 = offset.LengthSquared(); if (!float.IsFinite(num2) || num2 <= 0f) { return Vector3.Zero; } if (num2 <= num * num) { return offset; } return Vector3.Normalize(offset) * num; } } public sealed class LookAwayDismissal { public double AwaySeconds { get; private set; } public bool Update(bool observed, double deltaSeconds, double delaySeconds = 0.35) { if (observed) { AwaySeconds = 0.0; return false; } AwaySeconds += Math.Clamp(deltaSeconds, 0.0, 0.25); return AwaySeconds >= Math.Max(0.05, delaySeconds); } public void Reset() { AwaySeconds = 0.0; } } public readonly record struct AvatarPreviewMetrics(string Barcode, float VisualHeightMeters, bool Valid) { public string DisplayText { get { if (!Valid || !float.IsFinite(VisualHeightMeters) || !(VisualHeightMeters > 0f)) { return "Height unavailable"; } if (VisualHeightMeters < 10f) { return $"{VisualHeightMeters:0.00} m"; } return $"{VisualHeightMeters:0.0} m"; } } public string GameDisplayText { get { if (!Valid) { return "Game height unavailable"; } return "Game height " + DisplayText; } } } public enum AvatarPreviewVisualSource { Unavailable, FullColor, NativePreviewMesh } public enum AvatarPreviewHeightSource { Unavailable, AvatarMetadata, RendererBounds, PreviewMeshBounds } public readonly record struct AvatarResolvedHeight(float Meters, AvatarPreviewHeightSource Source) { public bool Valid => AvatarPreviewMath.IsUsableHeight(Meters); } public readonly record struct AvatarPreviewRouteMetadata(AvatarPreviewVisualSource VisualSource, AvatarPreviewHeightSource HeightSource, bool InteractionReady); public readonly record struct AvatarPreviewPlacement(float UniformScale, Vector3 Offset) { public bool Valid { get { if (float.IsFinite(UniformScale) && UniformScale > 0f && float.IsFinite(Offset.X) && float.IsFinite(Offset.Y)) { return float.IsFinite(Offset.Z); } return false; } } } public static class AvatarPreviewMath { public const float DisplayHeightMeters = 0.16f; public const float DisplayWidthMeters = 0.135f; public const float DisplayDepthMeters = 0.1f; public const float FrontFacingYawDegrees = 180f; public static float NormalizeScale(float visualHeightMeters, float displayHeightMeters = 0.16f) { if (!IsUsableHeight(visualHeightMeters)) { return 1f; } return Math.Clamp(displayHeightMeters, 0.05f, 0.3f) / visualHeightMeters; } public static bool IsUsableHeight(float value) { if (float.IsFinite(value)) { if (value >= 0.05f) { return value <= 100f; } return false; } return false; } public static float NormalizeBoundsScale(Vector3 visualSize) { if (!float.IsFinite(visualSize.Y) || visualSize.Y <= 0.0001f) { return 1f; } return 0.16f / visualSize.Y; } public static AvatarPreviewPlacement FitBounds(Vector3 visualCenter, Vector3 visualSize) { float num = NormalizeBoundsScale(visualSize); return new AvatarPreviewPlacement(num, -visualCenter * num); } public static AvatarResolvedHeight ResolveHeight(float avatarMetadataHeight, float rendererBoundsHeight, float previewMeshBoundsHeight = 0f) { if (IsUsableHeight(avatarMetadataHeight)) { return new AvatarResolvedHeight(avatarMetadataHeight, AvatarPreviewHeightSource.AvatarMetadata); } if (IsUsableHeight(rendererBoundsHeight)) { return new AvatarResolvedHeight(rendererBoundsHeight, AvatarPreviewHeightSource.RendererBounds); } if (IsUsableHeight(previewMeshBoundsHeight)) { return new AvatarResolvedHeight(previewMeshBoundsHeight, AvatarPreviewHeightSource.PreviewMeshBounds); } return new AvatarResolvedHeight(0f, AvatarPreviewHeightSource.Unavailable); } public static bool IsVisualRendererType(string? typeName) { if (!string.IsNullOrWhiteSpace(typeName) && !typeName.Contains("Particle", StringComparison.OrdinalIgnoreCase) && !typeName.Contains("Trail", StringComparison.OrdinalIgnoreCase)) { return !typeName.Contains("LineRenderer", StringComparison.OrdinalIgnoreCase); } return false; } public static bool IsSafePreview(float visualHeightMeters, int rendererCount, int vertexCount, bool constrained) { int num = (constrained ? 24 : 64); int num2 = (constrained ? 350000 : 1250000); if (IsUsableHeight(visualHeightMeters) && rendererCount > 0 && rendererCount <= num && vertexCount >= 0) { return vertexCount <= num2; } return false; } } public static class AvatarUiScaleMath { public const float ReferencePlayerHeightMeters = 1.75f; public static float ResolveMenuScale(float playerHeightMeters) { if (!AvatarPreviewMath.IsUsableHeight(playerHeightMeters)) { return 1f; } return playerHeightMeters / 1.75f; } } public static class DownloadPresentationMath { public static double Smooth(double displayed, double authoritative, double deltaSeconds, double speed = 8.0) { double num = Math.Clamp(displayed, 0.0, 1.0); double num2 = Math.Clamp(authoritative, 0.0, 1.0); if (num2 <= num || deltaSeconds <= 0.0) { return Math.Min(num, (num2 < num) ? num : num2); } double num3 = 1.0 - Math.Exp((0.0 - Math.Max(0.1, speed)) * Math.Min(deltaSeconds, 0.25)); double num4 = num + (num2 - num) * num3; if (!(num2 - num4 < 0.0005)) { return Math.Min(num4, num2); } return num2; } } public static class FingertipProbeMath { public static Vector3 ControllerTip(Vector3 position, Vector3 forward, float reachMeters = 0.075f) { Vector3 vector = ((forward.LengthSquared() > 0.0001f) ? Vector3.Normalize(forward) : Vector3.UnitZ); return position + vector * Math.Clamp(reachMeters, 0f, 0.15f); } public static Vector3 Extrapolate(Vector3 intermediate, Vector3 distal, float distalFraction = 0.65f) { Vector3 value = distal - intermediate; float num = value.Length(); if (!(num > 0.0001f)) { return distal; } return distal + Vector3.Normalize(value) * num * Math.Clamp(distalFraction, 0f, 1.5f); } } public static class WorldTextFacingMath { public const float FrontFacingLocalYawDegrees = 0f; public static bool IsFrontFacing(float localYawDegrees) { return Math.Abs(Math.IEEERemainder(localYawDegrees - 0f, 360.0)) < 0.0010000000474974513; } public static bool FacesViewer(Vector3 rootForward, Vector3 directionToViewer, float localYawDegrees) { if (rootForward.LengthSquared() < 0.0001f || directionToViewer.LengthSquared() < 0.0001f) { return false; } Vector3 vector = Vector3.Normalize(rootForward); Vector3 value = Vector3.Cross(Vector3.UnitY, vector); value = ((!(value.LengthSquared() < 0.0001f)) ? Vector3.Normalize(value) : Vector3.UnitX); float x = localYawDegrees * (float)Math.PI / 180f; return Vector3.Dot(Vector3.Normalize(-vector * MathF.Cos(x) - value * MathF.Sin(x)), Vector3.Normalize(directionToViewer)) > 0.999f; } } public enum AvatarRestoreDecision { NativeSaveOwnsSelection, WaitForNativeSave, RestorePrivateFallback, NoRestore } public static class AvatarRestorePolicy { public static AvatarRestoreDecision Decide(bool nativeSaveReadable, bool nativeSaveWaitExpired, string? privateFallbackBarcode) { if (nativeSaveReadable) { return AvatarRestoreDecision.NativeSaveOwnsSelection; } if (!nativeSaveWaitExpired) { return AvatarRestoreDecision.WaitForNativeSave; } if (!string.IsNullOrWhiteSpace(privateFallbackBarcode)) { return AvatarRestoreDecision.RestorePrivateFallback; } return AvatarRestoreDecision.NoRestore; } } public enum AvatarRigCompatibility { UnverifiedAllowed, Verified, Invalid } public readonly record struct AvatarRigValidationResult(AvatarRigCompatibility Compatibility, string Reason) { public bool CanEquip => Compatibility != AvatarRigCompatibility.Invalid; public bool IsVerified => Compatibility == AvatarRigCompatibility.Verified; public static AvatarRigValidationResult Ready() { return new AvatarRigValidationResult(AvatarRigCompatibility.Verified, string.Empty); } public static AvatarRigValidationResult Allow(string reason) { return new AvatarRigValidationResult(AvatarRigCompatibility.UnverifiedAllowed, reason); } public static AvatarRigValidationResult Reject(string reason) { return new AvatarRigValidationResult(AvatarRigCompatibility.Invalid, reason); } } public enum AvatarSharingState { Unregistered, Registering, Ready, Announced, PlatformLimited, Failed } public readonly record struct AvatarPlatformAvailability(long? WindowsFileId, long? AndroidFileId) { public bool HasWindows { get { long? windowsFileId = WindowsFileId; if (windowsFileId.HasValue) { return windowsFileId.GetValueOrDefault() > 0; } return false; } } public bool HasAndroid { get { long? androidFileId = AndroidFileId; if (androidFileId.HasValue) { return androidFileId.GetValueOrDefault() > 0; } return false; } } public bool HasAny { get { if (!HasWindows) { return HasAndroid; } return true; } } public bool IsLimited { get { if (HasAny) { if (HasWindows) { return !HasAndroid; } return true; } return false; } } public string Badge { get { if (!HasWindows || !HasAndroid) { if (!HasWindows) { if (!HasAndroid) { return "LOCAL ONLY"; } return "QUEST ONLY"; } return "PC ONLY"; } return "PC + QUEST"; } } } public static class HoloInteractionMath { public static int WrapIndex(int current, int delta, int count) { if (count <= 0) { return 0; } return ((current + delta) % count + count) % count; } public static float SmoothStep01(float value) { float num = Math.Clamp(value, 0f, 1f); return num * num * (3f - 2f * num); } public static bool IsIntentionalFlick(float lateralVelocity, float threshold) { return Math.Abs(lateralVelocity) >= Math.Max(0f, threshold); } } public enum HoloPerformanceTier { Full, Balanced, Minimal } public sealed class HoloPerformanceGovernor { private readonly bool _constrained; private double _averageFrameMs; private int _slowSamples; private int _fastSamples; public HoloPerformanceTier Tier { get; private set; } public int RendererLimit => Tier switch { HoloPerformanceTier.Full => 64, HoloPerformanceTier.Balanced => 24, _ => 12, }; public bool EffectsAllowed => Tier != HoloPerformanceTier.Minimal; public double AverageFrameMs => _averageFrameMs; public HoloPerformanceGovernor(bool constrained) { _constrained = constrained; Tier = (constrained ? HoloPerformanceTier.Balanced : HoloPerformanceTier.Full); } public bool Sample(double frameMilliseconds) { if (!_constrained || double.IsNaN(frameMilliseconds) || double.IsInfinity(frameMilliseconds)) { return false; } double num = Math.Clamp(frameMilliseconds, 1.0, 100.0); _averageFrameMs = ((_averageFrameMs <= 0.0) ? num : (_averageFrameMs * 0.94 + num * 0.06)); if (_averageFrameMs >= 21.0) { _slowSamples++; _fastSamples = 0; } else if (_averageFrameMs <= 15.5) { _fastSamples++; _slowSamples = 0; } else { _slowSamples = Math.Max(0, _slowSamples - 1); _fastSamples = Math.Max(0, _fastSamples - 1); } if (Tier == HoloPerformanceTier.Balanced && _slowSamples >= 45) { Tier = HoloPerformanceTier.Minimal; _slowSamples = 0; return true; } if (Tier == HoloPerformanceTier.Minimal && _fastSamples >= 240) { Tier = HoloPerformanceTier.Balanced; _fastSamples = 0; return true; } return false; } } public enum InstalledPackFocusState { None, Downloading, WaitingForPallet, Ready, TimedOut } public sealed class InstalledPackFocus { public long ModId { get; private set; } public int Generation { get; private set; } public double DeadlineSeconds { get; private set; } public InstalledPackFocusState State { get; private set; } public string SelectedBarcode { get; private set; } = string.Empty; public IReadOnlyList InstallDirectories { get; private set; } = Array.Empty(); public bool Active => State != InstalledPackFocusState.None; public int Begin(long modId, double nowSeconds, IEnumerable? installDirectories = null, double timeoutSeconds = 12.0) { Generation++; ModId = modId; DeadlineSeconds = nowSeconds + Math.Max(1.0, timeoutSeconds); State = InstalledPackFocusState.WaitingForPallet; SelectedBarcode = string.Empty; InstallDirectories = (installDirectories ?? Array.Empty()).Where((string path) => !string.IsNullOrWhiteSpace(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); return Generation; } public int BeginDownload(long modId) { Generation++; ModId = modId; DeadlineSeconds = double.PositiveInfinity; State = InstalledPackFocusState.Downloading; SelectedBarcode = string.Empty; InstallDirectories = Array.Empty(); return Generation; } public void MarkDownloaded(long modId, double nowSeconds, IEnumerable? installDirectories, double timeoutSeconds = 12.0) { if (modId == ModId && State != InstalledPackFocusState.None) { UpdateDirectories(modId, installDirectories); DeadlineSeconds = nowSeconds + Math.Max(1.0, timeoutSeconds); State = InstalledPackFocusState.WaitingForPallet; } } public void UpdateDirectories(long modId, IEnumerable? installDirectories) { if (modId == ModId && State != InstalledPackFocusState.None) { InstallDirectories = (installDirectories ?? Array.Empty()).Where((string path) => !string.IsNullOrWhiteSpace(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } } public bool TryBind(long modId, IEnumerable barcodes) { if (modId != ModId || State == InstalledPackFocusState.None) { return false; } string text = barcodes.FirstOrDefault((string value) => !string.IsNullOrWhiteSpace(value))?.Trim(); if (string.IsNullOrWhiteSpace(text)) { return false; } SelectedBarcode = text; State = InstalledPackFocusState.Ready; return true; } public bool UpdateTimeout(double nowSeconds) { if (State != InstalledPackFocusState.WaitingForPallet || nowSeconds < DeadlineSeconds) { return false; } State = InstalledPackFocusState.TimedOut; return true; } public void Retry(double nowSeconds, double timeoutSeconds = 12.0) { if (Active) { DeadlineSeconds = nowSeconds + Math.Max(1.0, timeoutSeconds); State = InstalledPackFocusState.WaitingForPallet; } } public void Clear() { ModId = 0L; DeadlineSeconds = 0.0; State = InstalledPackFocusState.None; SelectedBarcode = string.Empty; InstallDirectories = Array.Empty(); } } public sealed class LibraryRefreshGate { public bool Pending { get; private set; } = true; public double NotBeforeSeconds { get; private set; } public void Request(double nowSeconds, double debounceSeconds = 0.35) { double num = nowSeconds + Math.Max(0.0, debounceSeconds); NotBeforeSeconds = (Pending ? Math.Max(NotBeforeSeconds, num) : num); Pending = true; } public bool TryConsume(double nowSeconds) { if (!Pending || nowSeconds < NotBeforeSeconds) { return false; } Pending = false; NotBeforeSeconds = 0.0; return true; } } public readonly record struct TouchBounds(float CenterX, float CenterY, float CenterZ, float HalfX, float HalfY, float HalfZ) { public bool Contains(float x, float y, float z) { if (Math.Abs(x - CenterX) <= Math.Max(0f, HalfX) && Math.Abs(y - CenterY) <= Math.Max(0f, HalfY)) { return Math.Abs(z - CenterZ) <= Math.Max(0f, HalfZ); } return false; } } public sealed class TouchDebounce { public bool Latched { get; private set; } public bool Update(bool touching) { if (!touching) { Latched = false; return false; } if (Latched) { return false; } Latched = true; return true; } } public sealed class ErrorLogThrottle { private readonly Dictionary _lastLogged = new Dictionary(StringComparer.Ordinal); public bool ShouldLog(string signature, double nowSeconds, double intervalSeconds = 5.0) { if (string.IsNullOrWhiteSpace(signature)) { signature = "unknown"; } if (_lastLogged.TryGetValue(signature, out var value) && nowSeconds - value < Math.Max(0.1, intervalSeconds)) { return false; } _lastLogged[signature] = nowSeconds; if (_lastLogged.Count > 64) { double cutoff = nowSeconds - Math.Max(30.0, intervalSeconds * 4.0); string[] array = (from item in _lastLogged where item.Value < cutoff select item.Key).ToArray(); foreach (string key in array) { _lastLogged.Remove(key); } } return true; } } public static class PreviewShellRecovery { public static bool RequiresRebuild(bool shellAlive, bool fallbackAlive) { if (shellAlive) { return !fallbackAlive; } return true; } public static bool ShouldSchedule(string? selectedBarcode, string? loadedBarcode, string? requestedBarcode) { if (string.IsNullOrWhiteSpace(selectedBarcode)) { return false; } if (!string.Equals(selectedBarcode, loadedBarcode, StringComparison.OrdinalIgnoreCase)) { return !string.Equals(selectedBarcode, requestedBarcode, StringComparison.OrdinalIgnoreCase); } return false; } } public readonly record struct WristActivationState(bool Armed, double RingProgress, bool Activate); public static class WristActivationMath { public const double ActivationSeconds = 1.5; public static WristActivationState Evaluate(double observedSeconds, double armSeconds) { double num = Math.Max(0.0, observedSeconds); double num2 = ((double.IsFinite(armSeconds) && armSeconds > 0.0) ? armSeconds : 1.5); double num3 = Math.Clamp(num / num2, 0.0, 1.0); return new WristActivationState(num > 0.0, num3, num3 >= 1.0); } } public sealed class WristRearmGate { public const double RequiredLookAwaySeconds = 0.25; private double _lookAwaySeconds; public bool RequiresLookAway { get; private set; } public void RequireLookAway() { RequiresLookAway = true; _lookAwaySeconds = 0.0; } public bool Update(bool observed, double deltaSeconds) { if (!RequiresLookAway) { return true; } if (observed) { _lookAwaySeconds = 0.0; } else { _lookAwaySeconds += Math.Max(0.0, deltaSeconds); } if (_lookAwaySeconds >= 0.25) { RequiresLookAway = false; _lookAwaySeconds = 0.0; } return !RequiresLookAway; } } } namespace BoneHub.Installation { public static class NetworkerRegistrationWriter { private sealed record PalletInfo(string Barcode, string Version, string Author); private const long GameId = 3809L; private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions { WriteIndented = true }; public static async Task WriteAsync(ModIoMod mod, ModIoFile installedFile, ModIoFileResolver.PlatformFiles platformFiles, IReadOnlyList installedDirectories, string modsDirectory, CancellationToken cancellationToken = default(CancellationToken)) { string root = Path.GetFullPath(modsDirectory).TrimEnd(Path.DirectorySeparatorChar); List sidecars = new List(); List pallets = new List(); foreach (string installedDirectory in installedDirectories) { string package = ValidateChild(installedDirectory, root); await WriteModInfoAsync(package, mod, installedFile, platformFiles, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); foreach (string palletPath in PalletManifestLocator.FindAll(package, SearchOption.TopDirectoryOnly)) { cancellationToken.ThrowIfCancellationRequested(); PalletInfo pallet = await ReadPalletAsync(palletPath, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); string manifestPath = Path.Combine(root, SafeFileName(pallet.Barcode) + ".manifest"); string catalogPath = Directory.EnumerateFiles(package, "catalog_*.json", SearchOption.TopDirectoryOnly).FirstOrDefault() ?? Path.Combine(package, "catalog_" + pallet.Barcode + ".json"); JsonObject value = BuildManifest(pallet, palletPath, catalogPath, mod, installedFile, platformFiles); await WriteAtomicAsync(manifestPath, value, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); sidecars.Add(manifestPath); pallets.Add(pallet.Barcode); } } string warning = ((platformFiles.Windows == null || platformFiles.Android == null) ? ("This pack has no active file for " + ((platformFiles.Windows == null) ? "PC" : "Quest") + "; Fusion sharing is disabled for that platform.") : null); return new ModIoSharingMetadata { RegistrationVersion = 3, ModId = mod.Id, ModSlug = NonEmpty(mod.NameId, SafeSlug(mod.Name, mod.Id)), WindowsFileId = platformFiles.Windows?.Id, AndroidFileId = platformFiles.Android?.Id, SidecarManifestPaths = sidecars, PalletBarcodes = pallets, Warning = warning }; } private static JsonObject BuildManifest(PalletInfo pallet, string palletPath, string catalogPath, ModIoMod mod, ModIoFile installedFile, ModIoFileResolver.PlatformFiles files) { JsonObject jsonObject = new JsonObject(); int num = 3; string text = null; string text2 = null; JsonObject jsonObject2 = new JsonObject(); if (files.Windows != null) { text = num++.ToString(); jsonObject["pc"] = Ref(text, "mod-target-modio#0"); } if (files.Android != null) { text2 = num++.ToString(); jsonObject["android"] = Ref(text2, "mod-target-modio#0"); } string text3 = text ?? text2; if (text3 != null) { jsonObject[NetworkerKey(mod, installedFile)] = Ref(text3, "mod-target-modio#0"); } string text4 = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); jsonObject2["1"] = new JsonObject { ["palletBarcode"] = pallet.Barcode, ["palletPath"] = Path.GetFullPath(palletPath), ["catalogPath"] = Path.GetFullPath(catalogPath), ["version"] = NonEmpty(pallet.Version, "0.0.0"), ["installedDate"] = text4, ["updateDate"] = text4, ["modListing"] = Ref("2", "mod-listing#0"), ["active"] = true, ["isa"] = Type("pallet-manifest#0") }; jsonObject2["2"] = new JsonObject { ["barcode"] = pallet.Barcode, ["title"] = NonEmpty(mod.NameId, SafeSlug(mod.Name, mod.Id)), ["description"] = NonEmpty(mod.Summary, string.Empty), ["author"] = NonEmpty(mod.Author.DisplayName, NonEmpty(pallet.Author, "Unknown")), ["version"] = NonEmpty(installedFile.Version, "0.0.0"), ["thumbnailUrl"] = NonEmpty(mod.Logo.Thumbnail, string.Empty), ["targets"] = jsonObject, ["isa"] = Type("mod-listing#0") }; if (files.Windows != null && text != null) { jsonObject2[text] = Target(mod.Id, files.Windows.Id); } if (files.Android != null && text2 != null) { jsonObject2[text2] = Target(mod.Id, files.Android.Id); } return new JsonObject { ["version"] = 2, ["root"] = Ref("1", "pallet-manifest#0"), ["objects"] = jsonObject2 }; } private static async Task WriteModInfoAsync(string package, ModIoMod mod, ModIoFile installedFile, ModIoFileResolver.PlatformFiles files, CancellationToken cancellationToken) { JsonArray value = new JsonArray(((IEnumerable)mod.Tags).Select((Func)((ModIoTag tag) => Sanitize(tag.Name))).ToArray()); JsonObject value2 = new JsonObject { ["isValidMod"] = true, ["downloading"] = false, ["mature"] = mod.MaturityOption != 0, ["isTracked"] = true, ["modId"] = NonEmpty(mod.NameId, SafeSlug(mod.Name, mod.Id)), ["thumbnailLink"] = NonEmpty(mod.Logo.Thumbnail, string.Empty), ["modName"] = NonEmpty(mod.Name, "Avatar Pack"), ["modSummary"] = NonEmpty(mod.Summary, string.Empty), ["fileName"] = NonEmpty(installedFile.Filename, "modfile.zip"), ["windowsDownloadLink"] = files.Windows?.Download.BinaryUrl ?? "nothing", ["androidDownloadLink"] = files.Android?.Download.BinaryUrl ?? "nothing", ["directDownloadLink"] = "nothing", ["modDownloadPercentage"] = 100, ["numericalId"] = mod.Id.ToString(), ["version"] = NonEmpty(installedFile.Version, "0.0.0"), ["tags"] = value, ["author"] = NonEmpty(mod.Author.DisplayName, "Unknown"), ["structureVersion"] = 4, ["temp"] = false }; await WriteAtomicAsync(Path.Combine(package, "modinfo.json"), value2, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } private static async Task ReadPalletAsync(string path, CancellationToken cancellationToken) { JsonNode? obj = JsonNode.Parse(await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(continueOnCapturedContext: false)) ?? throw new InvalidDataException("The pallet manifest is empty."); string propertyName = obj["root"]?["ref"]?.GetValue() ?? "1"; JsonNode jsonNode = obj["objects"]?[propertyName] ?? throw new InvalidDataException("The pallet root is missing."); string obj2 = jsonNode["barcode"]?.GetValue()?.Trim(); if (string.IsNullOrWhiteSpace(obj2)) { throw new InvalidDataException("The pallet barcode is missing."); } return new PalletInfo(obj2, jsonNode["version"]?.GetValue() ?? "0.0.0", jsonNode["author"]?.GetValue() ?? "Unknown"); } private static string NetworkerKey(ModIoMod mod, ModIoFile file) { string[] array = (from tag in mod.Tags select Sanitize(tag.Name) into tag where tag.Length > 0 select tag).ToArray(); return $"networker;{mod.MaturityOption != 0};False;{file.FileSize};{Sanitize(file.Filename)};4;{Sanitize(mod.Name)};{array.Length}" + ((array.Length == 0) ? string.Empty : (";" + string.Join(";", array))); } private static JsonObject Ref(string value, string type) { return new JsonObject { ["ref"] = value, ["type"] = type }; } private static JsonObject Type(string type) { return new JsonObject { ["type"] = type }; } private static JsonObject Target(long modId, long fileId) { return new JsonObject { ["thumbnailOverride"] = string.Empty, ["gameId"] = 3809L, ["modId"] = modId, ["modfileId"] = fileId, ["isa"] = Type("mod-target-modio#0") }; } private static string NonEmpty(string? value, string fallback) { if (!string.IsNullOrWhiteSpace(value)) { return value.Trim(); } return fallback; } private static string SafeSlug(string? name, long modId) { string text = new string((from character in (name ?? string.Empty).Trim().ToLowerInvariant() select (!char.IsLetterOrDigit(character)) ? '-' : character).ToArray()).Trim('-'); if (text.Length != 0) { return text; } return "wristhub-mod-" + modId; } private static string Sanitize(string? value) { return (value ?? string.Empty).Replace(';', ' ').Trim(); } private static string SafeFileName(string value) { return string.Concat(value.Select((char character) => (!Path.GetInvalidFileNameChars().Contains(character)) ? character : '_')); } private static string ValidateChild(string path, string root) { string text = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar); if (!text.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) { throw new InvalidDataException("An installed directory is outside BONELAB's Mods directory."); } return text; } private static async Task WriteAtomicAsync(string path, JsonNode value, CancellationToken cancellationToken) { string temp = path + ".wristhub.tmp"; try { await File.WriteAllTextAsync(temp, value.ToJsonString(JsonOptions), cancellationToken).ConfigureAwait(continueOnCapturedContext: false); File.Move(temp, path, overwrite: true); } finally { if (File.Exists(temp)) { File.Delete(temp); } } } } public static class PalletManifestLocator { public static bool IsManifestFileName(string? fileName) { if (string.IsNullOrWhiteSpace(fileName)) { return false; } if (!fileName.Equals("pallet.json", StringComparison.OrdinalIgnoreCase)) { return fileName.EndsWith(".pallet.json", StringComparison.OrdinalIgnoreCase); } return true; } public static IReadOnlyList FindAll(string directory, SearchOption searchOption) { if (!Directory.Exists(directory)) { return Array.Empty(); } return (from path in Directory.EnumerateFiles(directory, "*.json", searchOption) where IsManifestFileName(Path.GetFileName(path)) select path).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } public static bool HasManifest(string directory) { return FindAll(directory, SearchOption.TopDirectoryOnly).Count > 0; } } public sealed class SafeModInstaller { private static readonly HashSet BlockedExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) { ".exe", ".dll", ".com", ".bat", ".cmd", ".ps1", ".vbs", ".js", ".jar", ".sh", ".so", ".dylib" }; private readonly BoneHubOptions _options; public SafeModInstaller(BoneHubOptions options) { _options = options; } public async Task> InstallAsync(string zipPath, long modId, CancellationToken cancellationToken = default(CancellationToken)) { string modsRoot = EnsureDirectory(_options.ModsDirectory); string path = $"{modId}-{Guid.NewGuid():N}"; string? directoryName = Path.GetDirectoryName(modsRoot.TrimEnd(Path.DirectorySeparatorChar)); string text = Path.Combine(directoryName, ".bonehub-staging"); string text2 = Path.Combine(directoryName, ".bonehub-backups"); RecoverInterruptedTransactions(modsRoot, text2); DeleteStaleStaging(text); string stagingRoot = Path.Combine(text, path); string backupRoot = Path.Combine(text2, path); Directory.CreateDirectory(stagingRoot); try { await ExtractValidatedAsync(zipPath, stagingRoot, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); List list = FindPackageRoots(stagingRoot); if (list.Count == 0) { throw new InvalidDataException("The archive has no pallet manifest (pallet.json or *.pallet.json) and is not a BONELAB content mod."); } Directory.CreateDirectory(backupRoot); List list2 = new List(); List<(string, string)> list3 = new List<(string, string)>(); try { foreach (string item in list) { cancellationToken.ThrowIfCancellationRequested(); string fileName = Path.GetFileName(item.TrimEnd(Path.DirectorySeparatorChar)); ValidateFolderName(fileName); string text3 = Path.Combine(modsRoot, fileName); string text4 = null; if (Directory.Exists(text3)) { text4 = Path.Combine(backupRoot, fileName); Directory.Move(text3, text4); } try { Directory.Move(item, text3); list3.Add((text3, text4)); } catch { if (text4 != null && Directory.Exists(text4) && !Directory.Exists(text3)) { Directory.Move(text4, text3); } throw; } list2.Add(text3); } return list2; } catch { for (int num = list3.Count - 1; num >= 0; num--) { (string, string) tuple = list3[num]; if (Directory.Exists(tuple.Item1)) { Directory.Delete(tuple.Item1, recursive: true); } if (tuple.Item2 != null && Directory.Exists(tuple.Item2)) { Directory.Move(tuple.Item2, tuple.Item1); } } throw; } } finally { TryDeleteDirectory(stagingRoot); TryDeleteDirectory(backupRoot); } } private async Task ExtractValidatedAsync(string zipPath, string destination, CancellationToken cancellationToken) { await using FileStream zipStream = File.OpenRead(zipPath); using ZipArchive archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: false); long extractedBytes = 0L; string destinationPrefix = Path.GetFullPath(destination) + Path.DirectorySeparatorChar; foreach (ZipArchiveEntry entry in archive.Entries) { cancellationToken.ThrowIfCancellationRequested(); if (IsSymbolicLink(entry)) { throw new InvalidDataException("Archive contains a symbolic link: " + entry.FullName); } string fullPath = Path.GetFullPath(Path.Combine(destination, entry.FullName)); if (!fullPath.StartsWith(destinationPrefix, StringComparison.OrdinalIgnoreCase)) { throw new InvalidDataException("Archive path escapes the install directory: " + entry.FullName); } if (string.IsNullOrEmpty(entry.Name)) { Directory.CreateDirectory(fullPath); continue; } if (BlockedExtensions.Contains(Path.GetExtension(entry.Name))) { throw new InvalidDataException("Archive contains blocked executable content: " + entry.FullName); } extractedBytes = checked(extractedBytes + entry.Length); if (extractedBytes > _options.MaximumExtractedBytes) { throw new InvalidDataException("Archive exceeds the configured extracted-size limit."); } Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); await using Stream input = entry.Open(); await using FileStream output = new FileStream(fullPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); byte[] buffer = new byte[131072]; long writtenForEntry = 0L; while (true) { int num = await input.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (num != 0) { writtenForEntry = checked(writtenForEntry + num); if (extractedBytes - entry.Length + writtenForEntry > _options.MaximumExtractedBytes) { throw new InvalidDataException("Archive exceeds the configured extracted-size limit."); } await output.WriteAsync(buffer.AsMemory(0, num), cancellationToken).ConfigureAwait(continueOnCapturedContext: false); continue; } break; } } } private static List FindPackageRoots(string stagingRoot) { List roots = (from path in PalletManifestLocator.FindAll(stagingRoot, SearchOption.AllDirectories) select Path.GetDirectoryName(path) into path where !string.Equals(path, stagingRoot, StringComparison.OrdinalIgnoreCase) select path).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); return roots.Where((string candidate) => !roots.Any((string other) => !string.Equals(candidate, other, StringComparison.OrdinalIgnoreCase) && candidate.StartsWith(other + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))).ToList(); } private static bool IsSymbolicLink(ZipArchiveEntry entry) { return ((entry.ExternalAttributes >> 16) & 0xF000) == 40960; } private static string EnsureDirectory(string path) { string fullPath = Path.GetFullPath(path); Directory.CreateDirectory(fullPath); return fullPath; } private static void ValidateFolderName(string name) { bool flag = string.IsNullOrWhiteSpace(name); if (!flag) { bool flag2 = ((name == "." || name == "..") ? true : false); flag = flag2; } if (flag || name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) { throw new InvalidDataException("The pallet has an invalid package folder name."); } } private static void TryDeleteDirectory(string path) { try { if (Directory.Exists(path)) { Directory.Delete(path, recursive: true); } } catch { } } private static void RecoverInterruptedTransactions(string modsRoot, string backupBase) { if (!Directory.Exists(backupBase)) { return; } foreach (string item in Directory.EnumerateDirectories(backupBase)) { foreach (string item2 in Directory.EnumerateDirectories(item)) { string fileName = Path.GetFileName(item2); ValidateFolderName(fileName); string text = Path.Combine(modsRoot, fileName); if (Directory.Exists(text)) { Directory.Delete(item2, recursive: true); } else { Directory.Move(item2, text); } } TryDeleteDirectory(item); } } private static void DeleteStaleStaging(string stagingBase) { if (!Directory.Exists(stagingBase)) { return; } foreach (string item in Directory.EnumerateDirectories(stagingBase)) { TryDeleteDirectory(item); } } } public static class StorageBudget { public const long SafetyReserveBytes = 134217728L; public static long RequiredBytes(long compressedBytes, long extractedBytes) { long num = Math.Max(0L, compressedBytes); long num2 = Math.Max(0L, extractedBytes); try { return checked(num + num2 + 134217728); } catch (OverflowException) { return long.MaxValue; } } public static bool HasCapacity(long availableBytes, long compressedBytes, long extractedBytes) { return availableBytes >= RequiredBytes(compressedBytes, extractedBytes); } public static long? TryGetAvailableBytes(string directory) { try { string fullPath = Path.GetFullPath(directory); return (from item in DriveInfo.GetDrives() where item.IsReady && fullPath.StartsWith(item.RootDirectory.FullName, StringComparison.OrdinalIgnoreCase) orderby item.RootDirectory.FullName.Length descending select item).FirstOrDefault()?.AvailableFreeSpace; } catch { return null; } } } } namespace BoneHub.Downloads { public enum DownloadState { Queued, Resolving, Downloading, Verifying, Installing, Completed, Failed, Cancelled } public sealed class DownloadJob { private readonly object _sync = new object(); public Guid Id { get; } = Guid.NewGuid(); public ModIoMod Mod { get; } public DownloadState State { get; private set; } public double Progress { get; private set; } public string? Error { get; private set; } public InstalledModRecord? Result { get; private set; } internal DownloadJob(ModIoMod mod) { Mod = mod; } internal void Update(DownloadState state, double progress, string? error = null, InstalledModRecord? result = null) { lock (_sync) { State = state; Progress = Math.Clamp(progress, 0.0, 1.0); Error = error; Result = result; } } } public sealed class InstalledModRecord { public long ModId { get; init; } public long FileId { get; init; } public string Name { get; init; } = string.Empty; public string Version { get; init; } = string.Empty; public string Md5 { get; init; } = string.Empty; public IReadOnlyList InstalledDirectories { get; init; } = Array.Empty(); public ModIoSharingMetadata? Sharing { get; init; } } public sealed class ModIoSharingMetadata { public int RegistrationVersion { get; init; } public long ModId { get; init; } public string ModSlug { get; init; } = string.Empty; public long? WindowsFileId { get; init; } public long? AndroidFileId { get; init; } public List SidecarManifestPaths { get; init; } = new List(); public List PalletBarcodes { get; init; } = new List(); public string? Warning { get; init; } } public sealed class DownloadManager : IDisposable { private sealed class InlineProgress : IProgress { private readonly Action _callback; public InlineProgress(Action callback) { _callback = callback; } public void Report(T value) { _callback(value); } } private readonly IModIoClient _client; private readonly SafeModInstaller _installer; private readonly BoneHubOptions _options; private readonly SemaphoreSlim _queue = new SemaphoreSlim(1, 1); private readonly CancellationTokenSource _shutdown = new CancellationTokenSource(); private readonly ConcurrentDictionary _jobCancellation = new ConcurrentDictionary(); private readonly ConcurrentDictionary _activeByMod = new ConcurrentDictionary(); private int _disposed; public event Action? JobChanged; public DownloadManager(IModIoClient client, SafeModInstaller installer, BoneHubOptions options) { _client = client; _installer = installer; _options = options; } public DownloadJob Enqueue(ModIoMod mod, CancellationToken cancellationToken = default(CancellationToken)) { if (_activeByMod.TryGetValue(mod.Id, out DownloadJob value)) { return value; } DownloadJob downloadJob = new DownloadJob(mod); CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); if (!_activeByMod.TryAdd(mod.Id, downloadJob)) { cancellationTokenSource.Dispose(); if (!_activeByMod.TryGetValue(mod.Id, out value)) { return Enqueue(mod, cancellationToken); } return value; } if (!_jobCancellation.TryAdd(downloadJob.Id, cancellationTokenSource)) { _activeByMod.TryRemove(mod.Id, out DownloadJob _); cancellationTokenSource.Dispose(); throw new InvalidOperationException("Could not register the download job."); } Notify(downloadJob); RunGuardedAsync(downloadJob, cancellationTokenSource, cancellationToken); return downloadJob; } public bool Cancel(Guid jobId) { if (!_jobCancellation.TryGetValue(jobId, out CancellationTokenSource value)) { return false; } try { value.Cancel(); return true; } catch (ObjectDisposedException) { return false; } } private async Task RunGuardedAsync(DownloadJob job, CancellationTokenSource jobCancellation, CancellationToken callerToken) { using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(callerToken, jobCancellation.Token, _shutdown.Token); _ = 1; try { await _queue.WaitAsync(linked.Token).ConfigureAwait(continueOnCapturedContext: false); try { await RunAsync(job, linked.Token).ConfigureAwait(continueOnCapturedContext: false); } finally { _queue.Release(); } } catch (OperationCanceledException) { job.Update(DownloadState.Cancelled, job.Progress); Notify(job); } catch (Exception ex2) { job.Update(DownloadState.Failed, job.Progress, ex2.Message); Notify(job); } finally { _jobCancellation.TryRemove(job.Id, out CancellationTokenSource _); _activeByMod.TryRemove(job.Mod.Id, out DownloadJob _); jobCancellation.Dispose(); } } private async Task RunAsync(DownloadJob job, CancellationToken cancellationToken) { ModIoFile modIoFile = job.Mod.Modfile ?? throw new InvalidOperationException("This mod has no live file."); Directory.CreateDirectory(_options.DataDirectory); string archivePath = Path.Combine(_options.DataDirectory, $"{job.Mod.Id}-{modIoFile.Id}-{job.Id:N}.zip.part"); try { ModIoFile file = null; for (int attempt = 0; attempt <= _options.DownloadRetryCount; attempt++) { cancellationToken.ThrowIfCancellationRequested(); try { job.Update(DownloadState.Resolving, 0.0, (attempt == 0) ? null : $"Retry {attempt} of {_options.DownloadRetryCount}"); Notify(job); file = await ModIoFileResolver.ResolveAsync(_client, job.Mod, _options, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); ValidateFile(file); long? num = StorageBudget.TryGetAvailableBytes(_options.DataDirectory); if (num.HasValue && !StorageBudget.HasCapacity(num.Value, file.FileSize, file.UncompressedSize)) { throw new IOException("There is not enough free storage to download, verify, and install this mod safely."); } if (!Uri.TryCreate(file.Download.BinaryUrl, UriKind.Absolute, out Uri result)) { throw new InvalidDataException("mod.io returned an invalid download URL."); } job.Update(DownloadState.Downloading, 0.02); Notify(job); int lastPercent = -1; InlineProgress progress = new InlineProgress(delegate(double value) { job.Update(DownloadState.Downloading, 0.02 + value * 0.73); int num2 = (int)(value * 100.0); if (num2 != lastPercent) { lastPercent = num2; Notify(job); } }); await using FileStream destination = new FileStream(archivePath, FileMode.Create, FileAccess.Write, FileShare.None); await _client.DownloadAsync(result, destination, _options.MaximumDownloadBytes, progress, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } catch (Exception exception) when (attempt < _options.DownloadRetryCount && IsTransient(exception, cancellationToken)) { TimeSpan delay = TimeSpan.FromSeconds(Math.Min(8, 1 << attempt)); job.Update(DownloadState.Resolving, job.Progress, $"Connection interrupted; retrying in {delay.TotalSeconds:0}s"); Notify(job); await Task.Delay(delay, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); continue; } break; } if (file == null) { throw new InvalidOperationException("Download resolution did not produce a mod file."); } ModIoFileResolver.PlatformFiles platformFiles; try { platformFiles = await ModIoFileResolver.ResolvePlatformFilesAsync(_client, job.Mod.Id, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } catch { platformFiles = (_options.CompatiblePlatforms.Any((string platform) => platform.Contains("android", StringComparison.OrdinalIgnoreCase) || platform.Contains("quest", StringComparison.OrdinalIgnoreCase) || platform.Contains("oculus", StringComparison.OrdinalIgnoreCase)) ? new ModIoFileResolver.PlatformFiles(null, file) : new ModIoFileResolver.PlatformFiles(file, null)); } job.Update(DownloadState.Verifying, 0.78); Notify(job); string actualHash = await ComputeMd5Async(archivePath, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (!string.IsNullOrWhiteSpace(file.Hash.Md5) && !string.Equals(actualHash, file.Hash.Md5, StringComparison.OrdinalIgnoreCase)) { throw new InvalidDataException("Downloaded file failed its mod.io MD5 integrity check."); } job.Update(DownloadState.Installing, 0.84); Notify(job); IReadOnlyList directories = await _installer.InstallAsync(archivePath, job.Mod.Id, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); ModIoSharingMetadata sharing; try { sharing = await NetworkerRegistrationWriter.WriteAsync(job.Mod, file, platformFiles, directories, _options.ModsDirectory, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } catch (Exception ex) { sharing = new ModIoSharingMetadata { ModId = job.Mod.Id, ModSlug = job.Mod.NameId, WindowsFileId = platformFiles.Windows?.Id, AndroidFileId = platformFiles.Android?.Id, Warning = "Fusion sharing registration failed: " + ex.Message }; } InstalledModRecord result2 = new InstalledModRecord { ModId = job.Mod.Id, FileId = file.Id, Name = job.Mod.Name, Version = file.Version, Md5 = actualHash, InstalledDirectories = directories, Sharing = sharing }; job.Update(DownloadState.Completed, 1.0, null, result2); Notify(job); } finally { try { if (File.Exists(archivePath)) { File.Delete(archivePath); } } catch { } } } private static bool IsTransient(Exception exception, CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested || exception is OperationCanceledException) { return false; } if ((exception is HttpRequestException || exception is IOException) ? true : false) { return true; } ModIoException ex = exception as ModIoException; bool flag = ex != null; int num; if (flag) { bool flag2; switch (ex.StatusCode) { case 408: case 425: case 429: flag2 = true; break; default: flag2 = false; break; } if (!flag2) { int? statusCode = ex.StatusCode; if (statusCode.HasValue) { int valueOrDefault = statusCode.GetValueOrDefault(); if (valueOrDefault >= 500) { num = ((valueOrDefault <= 599) ? 1 : 0); goto IL_00ae; } } num = 0; } else { num = 1; } goto IL_00ae; } goto IL_00af; IL_00af: return flag; IL_00ae: flag = (byte)num != 0; goto IL_00af; } private void ValidateFile(ModIoFile file) { if (file.VirusPositive != 0) { throw new InvalidDataException("mod.io flagged this file as harmful; installation was blocked."); } if (_options.RequireCompletedVirusScan && file.VirusStatus != 1) { throw new InvalidDataException("This file has not completed mod.io virus scanning."); } if (file.FileSize > _options.MaximumDownloadBytes) { throw new InvalidDataException("This file exceeds WristHub's configured download limit."); } if (file.UncompressedSize > _options.MaximumExtractedBytes) { throw new InvalidDataException("This file exceeds WristHub's configured extracted-size limit."); } if (!ModIoFileResolver.IsCompatible(file, _options)) { throw new InvalidDataException("This file is not approved for " + _options.PlatformDisplayName + "."); } } private static async Task ComputeMd5Async(string path, CancellationToken cancellationToken) { using MD5 md5 = MD5.Create(); string result; await using (FileStream stream = File.OpenRead(path)) { result = Convert.ToHexString(await md5.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(continueOnCapturedContext: false)).ToLowerInvariant(); } return result; } private void Notify(DownloadJob job) { this.JobChanged?.Invoke(job); } public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) != 0) { return; } _shutdown.Cancel(); foreach (CancellationTokenSource value in _jobCancellation.Values) { try { value.Cancel(); } catch (ObjectDisposedException) { } } _shutdown.Dispose(); } } public static class ModIoFileResolver { public sealed record PlatformFiles(ModIoFile? Windows, ModIoFile? Android); public static async Task ResolveAsync(IModIoClient client, ModIoMod mod, BoneHubOptions options, CancellationToken cancellationToken = default(CancellationToken)) { ModIoFile modIoFile = mod.Modfile ?? throw new InvalidOperationException("This mod has no live file."); ModIoFile modIoFile2 = await client.GetFileAsync(mod.Id, modIoFile.Id, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (IsCompatible(modIoFile2, options)) { return modIoFile2; } return (from file in (await client.GetFilesAsync(mod.Id, cancellationToken).ConfigureAwait(continueOnCapturedContext: false)).Data where IsCompatible(file, options) orderby file.DateAdded descending, file.Id descending select file).FirstOrDefault() ?? throw new InvalidDataException("No active " + options.PlatformDisplayName + " file is available for this mod."); } public static bool IsCompatible(ModIoFile file, BoneHubOptions options) { if (file.Platforms.Count != 0) { return file.Platforms.Any((ModIoPlatform platform) => platform.Status == 1 && (platform.Platform.Equals("ALL", StringComparison.OrdinalIgnoreCase) || options.CompatiblePlatforms.Any((string candidate) => platform.Platform.Equals(candidate, StringComparison.OrdinalIgnoreCase)))); } return true; } public static async Task ResolvePlatformFilesAsync(IModIoClient client, long modId, CancellationToken cancellationToken = default(CancellationToken)) { Task> windowsTask = client.GetFilesAsync(modId, "windows", cancellationToken); Task> androidTask = client.GetFilesAsync(modId, "android", cancellationToken); await Task.WhenAll>(windowsTask, androidTask).ConfigureAwait(continueOnCapturedContext: false); ModIoPage windowsFiles = await windowsTask.ConfigureAwait(continueOnCapturedContext: false); ModIoPage files = await androidTask.ConfigureAwait(continueOnCapturedContext: false); return new PlatformFiles(Pick(windowsFiles, new string[2] { "windows", "win" }), Pick(files, new string[3] { "android", "oculus", "quest" })); static ModIoFile? Pick(ModIoPage modIoPage, string[] platforms) { return (from file in modIoPage.Data where file.VirusPositive == 0 && (file.Platforms.Count == 0 || file.Platforms.Any((ModIoPlatform platform) => platform.Status == 1 && (platform.Platform.Equals("ALL", StringComparison.OrdinalIgnoreCase) || platforms.Any((string candidate) => platform.Platform.Equals(candidate, StringComparison.OrdinalIgnoreCase))))) orderby file.DateAdded descending, file.Id descending select file).FirstOrDefault(); } } } } namespace BoneHub.Configuration { public sealed class BoneHubOptions { public const int BonelabGameId = 3809; public const string DefaultHostedServiceBaseUrl = "https://wristhub-api.wristhub.workers.dev/v1"; public int GameId { get; init; } = 3809; public string ApiBaseUrl { get; init; } = "https://g-3809.modapi.io/v1"; public string ApiKey { get; set; } = string.Empty; public string ApiKeyFilePath { get; init; } = string.Empty; public string HostedServiceBaseUrl { get; init; } = "https://wristhub-api.wristhub.workers.dev/v1"; public string TargetPlatform { get; init; } = "windows"; public IReadOnlyList CompatiblePlatforms { get; init; } = new string[1] { "windows" }; public string PlatformDisplayName { get; init; } = "Windows PCVR"; public bool ConstrainedRendering { get; init; } public string ModsDirectory { get; init; } = string.Empty; public string DataDirectory { get; init; } = string.Empty; public int SearchPageSize { get; init; } = 20; public int CacheMinutes { get; init; } = 30; public long MaximumDownloadBytes { get; init; } = 2147483648L; public long MaximumExtractedBytes { get; init; } = 6442450944L; public bool RequireCompletedVirusScan { get; init; } = true; public int DownloadRetryCount { get; init; } = 3; public bool HasValidApiKey => IsValidApiKey(ApiKey); public bool HasHostedService { get { if (Uri.TryCreate(HostedServiceBaseUrl, UriKind.Absolute, out Uri result)) { return result.Scheme == Uri.UriSchemeHttps; } return false; } } public bool CanUseModIo { get { if (!HasHostedService) { return HasValidApiKey; } return true; } } public static bool IsValidApiKey(string? value) { if (value != null && value.Trim().Length == 32) { return value.Trim().All(char.IsLetterOrDigit); } return false; } public void Validate() { if (GameId <= 0) { throw new InvalidOperationException("GameId must be positive."); } if (!Uri.TryCreate(ApiBaseUrl, UriKind.Absolute, out Uri result) || result.Scheme != Uri.UriSchemeHttps) { throw new InvalidOperationException("ApiBaseUrl must be an absolute HTTPS URL."); } if (!string.IsNullOrWhiteSpace(HostedServiceBaseUrl) && !HasHostedService) { throw new InvalidOperationException("HostedServiceBaseUrl must be empty or an absolute HTTPS URL."); } if (string.IsNullOrWhiteSpace(ModsDirectory)) { throw new InvalidOperationException("ModsDirectory is required."); } if (string.IsNullOrWhiteSpace(DataDirectory)) { throw new InvalidOperationException("DataDirectory is required."); } if (CompatiblePlatforms.Count == 0 || CompatiblePlatforms.Any(string.IsNullOrWhiteSpace)) { throw new InvalidOperationException("At least one valid compatible platform is required."); } int searchPageSize = SearchPageSize; if ((searchPageSize < 1 || searchPageSize > 100) ? true : false) { throw new InvalidOperationException("SearchPageSize must be between 1 and 100."); } if (CacheMinutes < 0) { throw new InvalidOperationException("CacheMinutes cannot be negative."); } if (MaximumDownloadBytes <= 0 || MaximumExtractedBytes <= 0) { throw new InvalidOperationException("Download limits must be positive."); } searchPageSize = DownloadRetryCount; if ((searchPageSize < 0 || searchPageSize > 5) ? true : false) { throw new InvalidOperationException("DownloadRetryCount must be between 0 and 5."); } } } public sealed record BoneHubPlatformProfile(string Id, string DisplayName, string ModIoHeader, IReadOnlyList CompatibleModIoPlatforms, long MaximumDownloadBytes, long MaximumExtractedBytes, bool ConstrainedRendering) { public static BoneHubPlatformProfile Pcvr { get; } = new BoneHubPlatformProfile("pcvr", "Windows PCVR", "windows", new string[1] { "windows" }, 2147483648L, 6442450944L, ConstrainedRendering: false); public static BoneHubPlatformProfile Quest { get; } = new BoneHubPlatformProfile("quest", "Standalone Quest", "android", new string[2] { "android", "oculus" }, 1073741824L, 3221225472L, ConstrainedRendering: true); public static BoneHubPlatformProfile Detect(bool isAndroid) { if (!isAndroid) { return Pcvr; } return Quest; } [CompilerGenerated] private BoneHubPlatformProfile(BoneHubPlatformProfile original) { Id = original.Id; DisplayName = original.DisplayName; ModIoHeader = original.ModIoHeader; CompatibleModIoPlatforms = original.CompatibleModIoPlatforms; MaximumDownloadBytes = original.MaximumDownloadBytes; MaximumExtractedBytes = original.MaximumExtractedBytes; ConstrainedRendering = original.ConstrainedRendering; } } } namespace BoneHub.Compatibility { public sealed class FusionCompatibilityBridge { private readonly Func> _assemblies; private Assembly? _fusionAssembly; private Type? _networkInfoType; private Type? _localAvatarType; private Type? _rigDataType; private Type? _playerSenderType; private long _nextRefreshAt; public bool Installed { get { Refresh(); return _fusionAssembly != null; } } public bool SessionActive { get { Refresh(); return ReadStaticBool(_networkInfoType, "HasServer"); } } public string Version { get { Refresh(); return (_fusionAssembly?.GetType("LabFusion.FusionVersion")?.GetField("VersionString", BindingFlags.Static | BindingFlags.Public))?.GetValue(null)?.ToString() ?? "unknown"; } } public string Status { get { if (Installed) { if (!SessionActive) { return "Fusion " + Version + " detected · ready"; } return "Fusion " + Version + " session · avatar sync active"; } return "Fusion not detected · solo mode"; } } public FusionCompatibilityBridge(Func>? assemblies = null) { _assemblies = assemblies ?? ((Func>)(() => AppDomain.CurrentDomain.GetAssemblies())); } public bool CanEquip(string barcode, out string? error) { error = null; Refresh(); if (_localAvatarType == null) { return true; } string text = ReadStaticProperty(_localAvatarType, "AvatarOverride")?.ToString(); if (string.IsNullOrWhiteSpace(text) || string.Equals(text, barcode, StringComparison.OrdinalIgnoreCase)) { return true; } error = "Fusion currently has an avatar override. Disable the active Fusion gamemode/override before changing avatars."; return false; } public bool IsAvatarSynchronized(string barcode) { Refresh(); if (!SessionActive || _localAvatarType == null) { return true; } return string.Equals(ReadStaticProperty(_localAvatarType, "AvatarBarcode")?.ToString(), barcode, StringComparison.OrdinalIgnoreCase); } public bool TrySwapAvatar(string barcode, out string? error) { error = null; Refresh(); if (!SessionActive || _localAvatarType == null) { return false; } try { MethodInfo method = _localAvatarType.GetMethod("SwapAvatarCrate", BindingFlags.Static | BindingFlags.Public, null, new Type[1] { typeof(string) }, null); if (method == null) { error = "The installed Fusion version does not expose its avatar broadcast method."; return false; } method.Invoke(null, new object[1] { barcode }); return true; } catch (Exception ex) { error = ex.InnerException?.Message ?? ex.Message; return false; } } public bool TryResendCurrentAvatar(string barcode, out string? error) { error = null; Refresh(); if (!SessionActive || _rigDataType == null || _playerSenderType == null) { error = "Fusion's avatar sender is not ready."; return false; } try { if (!string.Equals(ReadStaticMember(_rigDataType, "RigAvatarId")?.ToString() ?? ReadStaticProperty(_localAvatarType, "AvatarBarcode")?.ToString(), barcode, StringComparison.OrdinalIgnoreCase)) { error = "Fusion is synchronized to a different avatar."; return false; } object obj = ReadStaticMember(_rigDataType, "RigAvatarStats"); if (obj == null) { error = "Fusion has not produced synchronized avatar statistics yet."; return false; } MethodInfo methodInfo = _playerSenderType.GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic).FirstOrDefault((MethodInfo method) => method.Name == "SendPlayerAvatar" && method.GetParameters().Length == 2); if (methodInfo == null) { error = "The installed Fusion version does not expose its avatar sender."; return false; } methodInfo.Invoke(null, new object[2] { obj, barcode }); return true; } catch (Exception ex) { error = ex.InnerException?.Message ?? ex.Message; return false; } } public void ForceRefresh() { _nextRefreshAt = 0L; Refresh(); } private void Refresh() { long tickCount = Environment.TickCount64; if (_fusionAssembly != null || tickCount < _nextRefreshAt) { return; } _nextRefreshAt = tickCount + 1000; try { _fusionAssembly = _assemblies().FirstOrDefault((Assembly assembly) => string.Equals(assembly.GetName().Name, "LabFusion", StringComparison.OrdinalIgnoreCase)); _networkInfoType = _fusionAssembly?.GetType("LabFusion.Network.NetworkInfo"); _localAvatarType = _fusionAssembly?.GetType("LabFusion.Player.LocalAvatar"); _rigDataType = _fusionAssembly?.GetType("LabFusion.Data.RigData"); _playerSenderType = _fusionAssembly?.GetType("LabFusion.Senders.PlayerSender"); } catch { _fusionAssembly = null; _networkInfoType = null; _localAvatarType = null; _rigDataType = null; _playerSenderType = null; } } private static bool ReadStaticBool(Type? type, string property) { object obj = ReadStaticProperty(type, property); bool flag = default(bool); int num; if (obj is bool) { flag = (bool)obj; num = 1; } else { num = 0; } return (byte)((uint)num & (flag ? 1u : 0u)) != 0; } private static object? ReadStaticProperty(Type? type, string property) { try { return type?.GetProperty(property, BindingFlags.Static | BindingFlags.Public)?.GetValue(null); } catch { return null; } } private static object? ReadStaticMember(Type? type, string name) { try { return type?.GetProperty(name, BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)?.GetValue(null) ?? type?.GetField(name, BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)?.GetValue(null); } catch { return null; } } } } namespace BoneHub.Avatars { public enum AvatarDownloadState { None, Queued, Downloading, Installing, Installed, Failed, Cancelled } public sealed record AvatarEntry(long ModId, string Barcode, string AvatarName, string ModName, string Creator, string Description, string ThumbnailUrl, bool Installed, bool Compatible = true, AvatarDownloadState DownloadState = AvatarDownloadState.None, double DownloadProgress = 0.0, string? Status = null) { public string Identity { get { if (string.IsNullOrWhiteSpace(Barcode)) { return "mod:" + ModId; } return "avatar:" + Barcode.Trim().ToLowerInvariant(); } } public AvatarEntry WithDownload(AvatarDownloadState state, double progress = 0.0, string? status = null) { return this with { DownloadState = state, DownloadProgress = Math.Clamp(progress, 0.0, 1.0), Status = status }; } public static AvatarEntry FromOnline(ModIoMod mod, bool installed) { return new AvatarEntry(mod.Id, string.Empty, mod.Name, mod.Name, mod.Author.DisplayName, mod.Summary, string.IsNullOrWhiteSpace(mod.Logo.Thumbnail) ? mod.Logo.Original : mod.Logo.Thumbnail, installed, Compatible: true, installed ? AvatarDownloadState.Installed : AvatarDownloadState.None); } } public sealed record LocalModMetadata(long ModId, string ModName, string Creator, string Summary, string ThumbnailUrl, IReadOnlyList Tags) { public static LocalModMetadata Read(string directory) { string fileName = Path.GetFileName(Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); string path = Path.Combine(directory, "modinfo.json"); if (!File.Exists(path)) { return new LocalModMetadata(StableLocalId(directory), fileName, "Unknown creator", string.Empty, string.Empty, Array.Empty()); } try { using JsonDocument jsonDocument = JsonDocument.Parse(File.ReadAllText(path)); JsonElement rootElement = jsonDocument.RootElement; long num = ReadLong(rootElement, "numericalId"); return new LocalModMetadata((num > 0) ? num : StableLocalId(directory), ReadString(rootElement, "modName", fileName), ReadString(rootElement, "author", "Unknown creator"), ReadString(rootElement, "modSummary", string.Empty), ReadString(rootElement, "thumbnailLink", string.Empty), ReadStrings(rootElement, "tags")); } catch (JsonException) { return new LocalModMetadata(StableLocalId(directory), fileName, "Unknown creator", string.Empty, string.Empty, Array.Empty()); } catch (IOException) { return new LocalModMetadata(StableLocalId(directory), fileName, "Unknown creator", string.Empty, string.Empty, Array.Empty()); } } private static string ReadString(JsonElement root, string name, string fallback) { if (!root.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(value.GetString())) { return fallback; } return value.GetString().Trim(); } private static long ReadLong(JsonElement root, string name) { if (!root.TryGetProperty(name, out var value)) { return 0L; } if (value.ValueKind != JsonValueKind.Number || !value.TryGetInt64(out var result)) { if (value.ValueKind != JsonValueKind.String || !long.TryParse(value.GetString(), out result)) { return 0L; } return result; } return result; } private static IReadOnlyList ReadStrings(JsonElement root, string name) { if (!root.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Array) { return Array.Empty(); } return (from item in value.EnumerateArray() where item.ValueKind == JsonValueKind.String select item.GetString().Trim() into item where item.Length > 0 select item).ToArray(); } private static long StableLocalId(string path) { long num = 1469598103934665603L; string text = Path.GetFullPath(path).ToUpperInvariant(); foreach (char c in text) { num = (num ^ c) * 1099511628211L; } if (num <= 0) { if (num != 0L) { return num; } return -9223372036854775807L; } return -num; } } public static class AvatarLibrary { public static IReadOnlyList Downloadable(IEnumerable entries) { return entries.Where((AvatarEntry entry) => !entry.Installed).OrderBy((AvatarEntry entry) => entry.AvatarName, StringComparer.OrdinalIgnoreCase).ThenBy((AvatarEntry entry) => entry.ModId) .ToArray(); } public static IReadOnlyList Merge(IEnumerable installed, IEnumerable online, string? query, IEnumerable? recentBarcodes = null) { string normalized = query?.Trim() ?? string.Empty; AvatarEntry[] source = installed.ToArray(); AvatarEntry[] first = source.Where((AvatarEntry entry) => Matches(entry, normalized)).ToArray(); HashSet installedModIds = (from entry in source where entry.ModId > 0 select entry.ModId).ToHashSet(); IEnumerable source2 = from @group in first.Concat(online.Where((AvatarEntry entry) => !installedModIds.Contains(entry.ModId))).GroupBy((AvatarEntry entry) => entry.Identity, StringComparer.OrdinalIgnoreCase) select @group.OrderByDescending((AvatarEntry entry) => entry.Installed).First(); Dictionary recent = (recentBarcodes ?? Array.Empty()).Select((string barcode, int index) => (barcode: barcode, index: index)).ToDictionary<(string, int), string, int>(((string barcode, int index) item) => item.barcode, ((string barcode, int index) item) => item.index, StringComparer.OrdinalIgnoreCase); int value; return (from entry in source2 orderby (!entry.Installed) ? 1 : 0, (!entry.Installed || !recent.TryGetValue(entry.Barcode, out value)) ? int.MaxValue : value select entry).ThenBy((AvatarEntry entry) => entry.AvatarName, StringComparer.OrdinalIgnoreCase).ToArray(); } private static bool Matches(AvatarEntry entry, string query) { if (query.Length != 0 && !entry.AvatarName.Contains(query, StringComparison.OrdinalIgnoreCase) && !entry.ModName.Contains(query, StringComparison.OrdinalIgnoreCase) && !entry.Creator.Contains(query, StringComparison.OrdinalIgnoreCase)) { return entry.Barcode.Contains(query, StringComparison.OrdinalIgnoreCase); } return true; } } } namespace BoneHub.Api { public interface IModIoClient { Task> SearchAsync(ModSearchRequest request, CancellationToken cancellationToken = default(CancellationToken)); Task GetModAsync(long modId, CancellationToken cancellationToken = default(CancellationToken)); Task GetFileAsync(long modId, long fileId, CancellationToken cancellationToken = default(CancellationToken)); Task> GetFilesAsync(long modId, CancellationToken cancellationToken = default(CancellationToken)); Task> GetFilesAsync(long modId, string platform, CancellationToken cancellationToken = default(CancellationToken)) { return GetFilesAsync(modId, cancellationToken); } Task> GetDependenciesAsync(long modId, CancellationToken cancellationToken = default(CancellationToken)); Task DownloadAsync(Uri binaryUri, Stream destination, long maximumBytes, IProgress? progress = null, CancellationToken cancellationToken = default(CancellationToken)); } public sealed class ModIoClient : IModIoClient, IDisposable { private readonly BoneHubOptions _options; private readonly HttpClient _http; private readonly bool _ownsClient; private readonly JsonSerializerOptions _json = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; public ModIoClient(BoneHubOptions options, HttpClient? httpClient = null) { _options = options; _options.Validate(); _http = httpClient ?? new HttpClient(); _ownsClient = httpClient == null; _http.Timeout = TimeSpan.FromMinutes(10.0); _http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); _http.DefaultRequestHeaders.UserAgent.ParseAdd("WristHub/2.0.1"); } public Task> SearchAsync(ModSearchRequest request, CancellationToken cancellationToken = default(CancellationToken)) { EnsureAvailable(); int limit = request.Limit; if ((limit < 1 || limit > 100) ? true : false) { throw new ArgumentOutOfRangeException("request", "Limit must be 1-100."); } Dictionary hostedQuery = new Dictionary { ["q"] = (string.IsNullOrWhiteSpace(request.Query) ? null : request.Query.Trim()), ["creator"] = (string.IsNullOrWhiteSpace(request.Creator) ? null : request.Creator.Trim()), ["sort"] = SortValue(request.Sort), ["offset"] = request.Offset.ToString(), ["limit"] = request.Limit.ToString() }; Dictionary directQuery = new Dictionary { ["api_key"] = _options.ApiKey, ["_q"] = (string.IsNullOrWhiteSpace(request.Query) ? null : request.Query.Trim()), ["tags"] = (string.IsNullOrWhiteSpace(request.Tag) ? "Avatar" : request.Tag.Trim()), ["submitted_by_display_name-lk"] = (string.IsNullOrWhiteSpace(request.Creator) ? null : ("*" + request.Creator.Trim() + "*")), ["_sort"] = SortValue(request.Sort), ["_offset"] = request.Offset.ToString(), ["_limit"] = request.Limit.ToString() }; return GetWithFallbackAsync>("mods", hostedQuery, $"games/{_options.GameId}/mods", directQuery, _options.TargetPlatform, cancellationToken); } public Task GetFileAsync(long modId, long fileId, CancellationToken cancellationToken = default(CancellationToken)) { EnsureAvailable(); if (modId <= 0) { throw new ArgumentOutOfRangeException("modId"); } if (fileId <= 0) { throw new ArgumentOutOfRangeException("fileId"); } return GetWithFallbackAsync($"mods/{modId}/files/{fileId}", EmptyQuery(), $"games/{_options.GameId}/mods/{modId}/files/{fileId}", ApiKeyQuery(), _options.TargetPlatform, cancellationToken); } public Task> GetFilesAsync(long modId, CancellationToken cancellationToken = default(CancellationToken)) { return GetFilesAsync(modId, _options.TargetPlatform, cancellationToken); } public Task> GetFilesAsync(long modId, string platform, CancellationToken cancellationToken = default(CancellationToken)) { EnsureAvailable(); if (modId <= 0) { throw new ArgumentOutOfRangeException("modId"); } if (string.IsNullOrWhiteSpace(platform)) { throw new ArgumentException("A mod.io platform is required.", "platform"); } Dictionary dictionary = ApiKeyQuery(); dictionary["_sort"] = "-date_added"; dictionary["_limit"] = "100"; return GetWithFallbackAsync>($"mods/{modId}/files", EmptyQuery(), $"games/{_options.GameId}/mods/{modId}/files", dictionary, platform.Trim(), cancellationToken); } public Task GetModAsync(long modId, CancellationToken cancellationToken = default(CancellationToken)) { EnsureAvailable(); if (modId <= 0) { throw new ArgumentOutOfRangeException("modId"); } return GetWithFallbackAsync($"mods/{modId}", EmptyQuery(), $"games/{_options.GameId}/mods/{modId}", ApiKeyQuery(), _options.TargetPlatform, cancellationToken); } public Task> GetDependenciesAsync(long modId, CancellationToken cancellationToken = default(CancellationToken)) { EnsureAvailable(); if (modId <= 0) { throw new ArgumentOutOfRangeException("modId"); } Dictionary dictionary = ApiKeyQuery(); dictionary["recursive"] = "false"; dictionary["_limit"] = "100"; return GetWithFallbackAsync>($"mods/{modId}/dependencies", EmptyQuery(), $"games/{_options.GameId}/mods/{modId}/dependencies", dictionary, _options.TargetPlatform, cancellationToken); } public async Task DownloadAsync(Uri binaryUri, Stream destination, long maximumBytes, IProgress? progress = null, CancellationToken cancellationToken = default(CancellationToken)) { if (binaryUri.Scheme != Uri.UriSchemeHttps) { throw new ModIoException("Refusing a non-HTTPS download URL."); } using HttpResponseMessage response = await _http.GetAsync(binaryUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); long? length = response.Content.Headers.ContentLength; if (length > maximumBytes) { throw new ModIoException($"Download is larger than the {maximumBytes} byte limit."); } await using Stream source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false); byte[] buffer = new byte[131072]; long total = 0L; while (true) { int num = await source.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (num != 0) { total += num; if (total > maximumBytes) { throw new ModIoException($"Download exceeded the {maximumBytes} byte limit."); } await destination.WriteAsync(buffer.AsMemory(0, num), cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (length.HasValue && length.GetValueOrDefault() > 0) { progress?.Report(Math.Min(1.0, (double)total / (double)length.Value)); } continue; } break; } } private async Task GetWithFallbackAsync(string hostedPath, IReadOnlyDictionary hostedQuery, string directPath, IReadOnlyDictionary directQuery, string platform, CancellationToken cancellationToken) { Exception ex = null; if (_options.HasHostedService) { try { return await GetJsonAsync(BuildUri(_options.HostedServiceBaseUrl, hostedPath, hostedQuery), platform, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } catch (Exception ex2) when (!cancellationToken.IsCancellationRequested && CanUseLocalFallback(ex2)) { ex = ex2; } } if (_options.HasValidApiKey) { return await GetJsonAsync(BuildUri(_options.ApiBaseUrl, directPath, directQuery), platform, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); } if (ex is ModIoException ex3) { throw new ModIoException("WristHub's avatar service is temporarily unavailable. Try again shortly.", ex3.StatusCode, ex3); } if (ex != null) { Exception inner = ex; throw new ModIoException("WristHub's avatar service is temporarily unavailable. Try again shortly.", null, inner); } throw new ModIoException("WristHub's avatar service is not configured."); } private async Task GetJsonAsync(Uri uri, string platform, CancellationToken cancellationToken) { using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, uri); request.Headers.TryAddWithoutValidation("X-Modio-Platform", platform); using HttpResponseMessage response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); T result; await using (Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false)) { T val = await JsonSerializer.DeserializeAsync(stream, _json, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (val == null) { throw new ModIoException("The avatar catalog returned an empty JSON response."); } result = val; } return result; } private static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken) { if (response.IsSuccessStatusCode) { return; } string text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (text.Length > 600) { text = text.Substring(0, 600); } if (response.StatusCode == HttpStatusCode.TooManyRequests) { throw new ModIoException("The avatar catalog is busy; wait and try again.", (int)response.StatusCode); } throw new ModIoException($"The avatar catalog returned {response.StatusCode} {response.ReasonPhrase}: {text}", (int)response.StatusCode); } private static Uri BuildUri(string baseUrl, string relativePath, IReadOnlyDictionary values) { string text = string.Join("&", from pair in values where pair.Value != null select Uri.EscapeDataString(pair.Key) + "=" + Uri.EscapeDataString(pair.Value)); string value = ((text.Length == 0) ? string.Empty : "?"); return new Uri($"{baseUrl.TrimEnd('/')}/{relativePath.TrimStart('/')}{value}{text}", UriKind.Absolute); } private Dictionary ApiKeyQuery() { return new Dictionary { ["api_key"] = _options.ApiKey }; } private static Dictionary EmptyQuery() { return new Dictionary(); } private void EnsureAvailable() { if (!_options.CanUseModIo) { throw new ModIoException("WristHub's avatar service is unavailable. A local mod.io key can be added in Setup as a fallback."); } } private bool CanUseLocalFallback(Exception exception) { if ((exception is HttpRequestException || exception is IOException) ? true : false) { return true; } ModIoException ex = exception as ModIoException; bool flag = ex != null; int num; if (flag) { bool flag2; switch (ex.StatusCode) { case 401: case 403: case 408: case 425: case 429: flag2 = true; break; default: flag2 = false; break; } if (!flag2) { int? statusCode = ex.StatusCode; if (statusCode.HasValue) { int valueOrDefault = statusCode.GetValueOrDefault(); if (valueOrDefault >= 500) { num = ((valueOrDefault <= 599) ? 1 : 0); goto IL_00b8; } } num = 0; } else { num = 1; } goto IL_00b8; } goto IL_00b9; IL_00b9: return flag; IL_00b8: flag = (byte)num != 0; goto IL_00b9; } private static string SortValue(ModSort sort) { return sort switch { ModSort.Newest => "-date_live", ModSort.MostDownloaded => "-downloads", ModSort.RecentlyUpdated => "-date_updated", _ => "-popular", }; } public void Dispose() { if (_ownsClient) { _http.Dispose(); } } } public sealed class ModIoException : Exception { public int? StatusCode { get; } public ModIoException(string message, int? statusCode = null, Exception? inner = null) : base(message, inner) { StatusCode = statusCode; } } }