MosswartMassacre/MosswartMassacre/MetaSyncManager.cs
Erik e20f9df256 feat: add searchable metas sync tab and configurable auto-updates
Add a Metas tab that lists remote .met/.nav files, checks update status, and downloads with .bak backups on overwrite. Add an Auto Install Updates setting (default on) and guard settings usage during early startup to avoid initialization errors.
2026-03-09 11:36:47 +01:00

419 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Microsoft.Win32;
using Newtonsoft.Json.Linq;
namespace MosswartMassacre
{
internal enum MetaFileSyncStatus
{
Unknown,
NotDownloaded,
UpToDate,
UpdateAvailable,
Error
}
internal sealed class MetaFileEntry
{
public string RelativePath { get; set; }
public string FileName { get; set; }
public string Extension { get; set; }
public string RemoteBlobSha { get; set; }
public string LocalPath { get; set; }
public bool LocalExists { get; set; }
public string LocalSha256 { get; set; }
public string RemoteSha256 { get; set; }
public MetaFileSyncStatus Status { get; set; }
public string StatusText { get; set; }
}
internal sealed class MetaDownloadResult
{
public string RelativePath { get; set; }
public bool Downloaded { get; set; }
public bool SkippedAsCurrent { get; set; }
public bool BackupCreated { get; set; }
public string Message { get; set; }
}
internal static class MetaSyncManager
{
private const string RepoOwner = "SawatoMosswartsEnjoyersClub";
private const string RepoName = "metas";
private const string BranchName = "main";
private const string ApiBaseUrl = "https://git.snakedesert.se/api/v1";
private const string RawBaseUrl = "https://git.snakedesert.se/SawatoMosswartsEnjoyersClub/metas/raw/branch/main";
private static readonly HttpClient HttpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
private static readonly object Sync = new object();
private static List<MetaFileEntry> _cachedFiles = new List<MetaFileEntry>();
public static DateTime LastCatalogRefreshUtc { get; private set; } = DateTime.MinValue;
public static DateTime LastStatusCheckUtc { get; private set; } = DateTime.MinValue;
public static IReadOnlyList<MetaFileEntry> GetCachedFiles()
{
lock (Sync)
{
return _cachedFiles
.Select(CloneEntry)
.ToList()
.AsReadOnly();
}
}
public static string GetResolvedVtankPath()
{
try
{
if (!string.IsNullOrWhiteSpace(PluginSettings.Instance?.VTankProfilesPath) &&
Directory.Exists(PluginSettings.Instance.VTankProfilesPath))
{
return PluginSettings.Instance.VTankProfilesPath;
}
var defaultPath = @"C:\Games\VirindiPlugins\VirindiTank\";
try
{
var regKey = Registry.LocalMachine.OpenSubKey("Software\\Decal\\Plugins\\{642F1F48-16BE-48BF-B1D4-286652C4533E}");
if (regKey != null)
{
var profilePath = regKey.GetValue("ProfilePath")?.ToString();
if (!string.IsNullOrWhiteSpace(profilePath) && Directory.Exists(profilePath))
{
return profilePath;
}
}
}
catch
{
}
return defaultPath;
}
catch
{
return string.Empty;
}
}
public static async Task<int> RefreshCatalogAsync()
{
var remoteEntries = await FetchRemoteTreeEntriesAsync();
var vtankPath = GetResolvedVtankPath();
var result = remoteEntries
.Where(path => path.EndsWith(".met", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".nav", StringComparison.OrdinalIgnoreCase))
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.Select(path =>
{
var localPath = string.IsNullOrWhiteSpace(vtankPath)
? string.Empty
: Path.Combine(vtankPath, Path.GetFileName(path));
var exists = !string.IsNullOrWhiteSpace(localPath) && File.Exists(localPath);
return new MetaFileEntry
{
RelativePath = path,
FileName = Path.GetFileName(path),
Extension = Path.GetExtension(path)?.TrimStart('.').ToLowerInvariant() ?? string.Empty,
RemoteBlobSha = string.Empty,
LocalPath = localPath,
LocalExists = exists,
Status = exists ? MetaFileSyncStatus.Unknown : MetaFileSyncStatus.NotDownloaded,
StatusText = exists ? "Unknown" : "Not downloaded"
};
})
.ToList();
lock (Sync)
{
_cachedFiles = result;
LastCatalogRefreshUtc = DateTime.UtcNow;
}
return result.Count;
}
public static async Task<(int upToDate, int needsUpdate, int notDownloaded, int errors)> CheckStatusesAsync()
{
List<MetaFileEntry> workingSet;
lock (Sync)
{
workingSet = _cachedFiles.Select(CloneEntry).ToList();
}
if (workingSet.Count == 0)
{
await RefreshCatalogAsync();
lock (Sync)
{
workingSet = _cachedFiles.Select(CloneEntry).ToList();
}
}
foreach (var entry in workingSet)
{
try
{
if (string.IsNullOrWhiteSpace(entry.LocalPath) || !File.Exists(entry.LocalPath))
{
entry.LocalExists = false;
entry.LocalSha256 = string.Empty;
entry.Status = MetaFileSyncStatus.NotDownloaded;
entry.StatusText = "Not downloaded";
continue;
}
entry.LocalExists = true;
entry.LocalSha256 = CalculateFileSha256(entry.LocalPath);
var remoteBytes = await DownloadRemoteFileBytesAsync(entry.RelativePath);
entry.RemoteSha256 = CalculateSha256(remoteBytes);
if (string.Equals(entry.LocalSha256, entry.RemoteSha256, StringComparison.OrdinalIgnoreCase))
{
entry.Status = MetaFileSyncStatus.UpToDate;
entry.StatusText = "Up to date";
}
else
{
entry.Status = MetaFileSyncStatus.UpdateAvailable;
entry.StatusText = "Update available";
}
}
catch (Exception ex)
{
entry.Status = MetaFileSyncStatus.Error;
entry.StatusText = "Error";
PluginCore.WriteToChat($"[Metas] Status check failed for {entry.RelativePath}: {ex.Message}");
}
}
lock (Sync)
{
_cachedFiles = workingSet;
LastStatusCheckUtc = DateTime.UtcNow;
}
var upToDate = workingSet.Count(x => x.Status == MetaFileSyncStatus.UpToDate);
var needsUpdate = workingSet.Count(x => x.Status == MetaFileSyncStatus.UpdateAvailable);
var notDownloaded = workingSet.Count(x => x.Status == MetaFileSyncStatus.NotDownloaded);
var errors = workingSet.Count(x => x.Status == MetaFileSyncStatus.Error);
return (upToDate, needsUpdate, notDownloaded, errors);
}
public static async Task<MetaDownloadResult> DownloadFileAsync(string relativePath)
{
var result = new MetaDownloadResult
{
RelativePath = relativePath,
Downloaded = false,
SkippedAsCurrent = false,
BackupCreated = false,
Message = string.Empty
};
if (string.IsNullOrWhiteSpace(relativePath))
{
result.Message = "No file selected.";
return result;
}
var vtankPath = GetResolvedVtankPath();
if (string.IsNullOrWhiteSpace(vtankPath))
{
result.Message = "VTank path could not be resolved.";
return result;
}
if (!Directory.Exists(vtankPath))
{
Directory.CreateDirectory(vtankPath);
}
var localPath = Path.Combine(vtankPath, Path.GetFileName(relativePath));
var remoteBytes = await DownloadRemoteFileBytesAsync(relativePath);
var remoteHash = CalculateSha256(remoteBytes);
if (File.Exists(localPath))
{
var localHash = CalculateFileSha256(localPath);
if (string.Equals(localHash, remoteHash, StringComparison.OrdinalIgnoreCase))
{
result.SkippedAsCurrent = true;
result.Message = "Already up to date.";
await UpdateSingleCachedEntryAsync(relativePath, localPath, localHash, remoteHash, MetaFileSyncStatus.UpToDate, "Up to date");
return result;
}
var backupPath = localPath + ".bak";
File.Copy(localPath, backupPath, true);
result.BackupCreated = true;
}
var tempPath = localPath + ".tmp";
File.WriteAllBytes(tempPath, remoteBytes);
if (File.Exists(localPath))
{
File.Delete(localPath);
}
File.Move(tempPath, localPath);
result.Downloaded = true;
result.Message = result.BackupCreated ? "Downloaded and updated (.bak created)." : "Downloaded.";
await UpdateSingleCachedEntryAsync(relativePath, localPath, CalculateFileSha256(localPath), remoteHash, MetaFileSyncStatus.UpToDate, "Up to date");
return result;
}
public static async Task<(int downloaded, int skipped, int failed, int backups)> DownloadOutdatedAsync()
{
List<MetaFileEntry> current;
lock (Sync)
{
current = _cachedFiles.Select(CloneEntry).ToList();
}
if (current.Count == 0)
{
await RefreshCatalogAsync();
current = GetCachedFiles().Select(CloneEntry).ToList();
}
var candidates = current
.Where(x => x.Status == MetaFileSyncStatus.UpdateAvailable || x.Status == MetaFileSyncStatus.NotDownloaded || x.Status == MetaFileSyncStatus.Unknown)
.OrderBy(x => x.RelativePath, StringComparer.OrdinalIgnoreCase)
.ToList();
var downloaded = 0;
var skipped = 0;
var failed = 0;
var backups = 0;
foreach (var entry in candidates)
{
try
{
var result = await DownloadFileAsync(entry.RelativePath);
if (result.Downloaded) downloaded++;
if (result.SkippedAsCurrent) skipped++;
if (result.BackupCreated) backups++;
}
catch (Exception ex)
{
failed++;
PluginCore.WriteToChat($"[Metas] Download failed for {entry.RelativePath}: {ex.Message}");
await UpdateSingleCachedEntryAsync(entry.RelativePath, entry.LocalPath, entry.LocalSha256, entry.RemoteSha256, MetaFileSyncStatus.Error, "Error");
}
}
return (downloaded, skipped, failed, backups);
}
private static async Task<List<string>> FetchRemoteTreeEntriesAsync()
{
var treeUrl = $"{ApiBaseUrl}/repos/{RepoOwner}/{RepoName}/git/trees/{BranchName}?recursive=1";
using (var response = await HttpClient.GetAsync(treeUrl))
{
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var parsed = JObject.Parse(json);
var tree = parsed["tree"] as JArray;
if (tree == null)
{
return new List<string>();
}
return tree
.Where(x => string.Equals(x["type"]?.ToString(), "blob", StringComparison.OrdinalIgnoreCase))
.Select(x => x["path"]?.ToString())
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
}
}
private static async Task<byte[]> DownloadRemoteFileBytesAsync(string relativePath)
{
var escapedPath = string.Join("/", (relativePath ?? string.Empty)
.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries)
.Select(Uri.EscapeDataString));
var fileUrl = $"{RawBaseUrl}/{escapedPath}";
return await HttpClient.GetByteArrayAsync(fileUrl);
}
private static string CalculateFileSha256(string path)
{
using (var stream = File.OpenRead(path))
{
using (var sha = SHA256.Create())
{
return ToLowerHex(sha.ComputeHash(stream));
}
}
}
private static string CalculateSha256(byte[] data)
{
using (var sha = SHA256.Create())
{
return ToLowerHex(sha.ComputeHash(data));
}
}
private static string ToLowerHex(byte[] hash)
{
return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant();
}
private static async Task UpdateSingleCachedEntryAsync(string relativePath, string localPath, string localHash, string remoteHash, MetaFileSyncStatus status, string statusText)
{
await Task.Run(() =>
{
lock (Sync)
{
var target = _cachedFiles.FirstOrDefault(x => string.Equals(x.RelativePath, relativePath, StringComparison.OrdinalIgnoreCase));
if (target == null)
{
return;
}
target.LocalPath = localPath;
target.LocalExists = !string.IsNullOrWhiteSpace(localPath) && File.Exists(localPath);
target.LocalSha256 = localHash ?? string.Empty;
target.RemoteSha256 = remoteHash ?? string.Empty;
target.Status = status;
target.StatusText = statusText ?? "Unknown";
}
});
}
private static MetaFileEntry CloneEntry(MetaFileEntry entry)
{
return new MetaFileEntry
{
RelativePath = entry.RelativePath,
FileName = entry.FileName,
Extension = entry.Extension,
RemoteBlobSha = entry.RemoteBlobSha,
LocalPath = entry.LocalPath,
LocalExists = entry.LocalExists,
LocalSha256 = entry.LocalSha256,
RemoteSha256 = entry.RemoteSha256,
Status = entry.Status,
StatusText = entry.StatusText
};
}
}
}