using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Security.Cryptography; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using ModpackLauncher.Models; namespace ModpackLauncher.Services; public sealed class ManifestSyncService { private const string PackVersionFile = "pack-version.json"; private static readonly string[] ManagedRoots = { "mods", "config", "resourcepacks", "shaderpacks", "kubejs", "defaultconfigs" }; private readonly HttpClient _http; public ManifestSyncService(HttpClient? http = null) { _http = http ?? new HttpClient(); _http.Timeout = TimeSpan.FromMinutes(5); } /// Configure HTTP Basic auth for all subsequent requests. Pass null to clear. public void SetBasicAuth(string? username, string? password) { if (string.IsNullOrEmpty(username)) { _http.DefaultRequestHeaders.Authorization = null; return; } var raw = $"{username}:{password ?? ""}"; var b64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(raw)); _http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", b64); } public sealed record SyncResult(Manifest Manifest, int Downloaded, int Removed); /// Just fetch the manifest JSON without doing the full file sync. Used for "is an update available?" checks on startup. public async Task FetchManifestOnlyAsync(string manifestUrl, CancellationToken ct = default) { var json = await _http.GetStringAsync(manifestUrl, ct).ConfigureAwait(false); var manifest = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (manifest == null) throw new InvalidOperationException("Manifest is empty or invalid."); manifest.Files ??= new System.Collections.Generic.List(); return manifest; } /// /// Fast check (no hashing): which files listed in the manifest are missing on disk. /// Used as a pre-launch sanity pass to catch AV quarantines / interrupted installs. /// public System.Collections.Generic.List FindMissingFiles(Manifest manifest, string installDir) { var missing = new System.Collections.Generic.List(); foreach (var file in manifest.Files) { var dest = Path.Combine(installDir, file.Path); if (!File.Exists(dest)) missing.Add(file); } return missing; } public PackVersionRecord? GetLocalPackVersion(string installDir) { var path = Path.Combine(installDir, PackVersionFile); if (!File.Exists(path)) return null; try { return JsonSerializer.Deserialize(File.ReadAllText(path)); } catch { return null; } } public async Task SyncAsync( string manifestUrl, string installDir, IProgress progress, CancellationToken ct = default) { progress.Report(new ProgressReport(ProgressKind.Status, "Fetching manifest...")); var manifestJson = await _http.GetStringAsync(manifestUrl, ct).ConfigureAwait(false); var manifest = JsonSerializer.Deserialize(manifestJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? throw new InvalidOperationException("Manifest is empty or invalid."); if (string.IsNullOrWhiteSpace(manifest.Minecraft.Version)) { throw new InvalidOperationException("Manifest is missing minecraft.version."); } Directory.CreateDirectory(installDir); var local = GetLocalPackVersion(installDir); progress.Report(new ProgressReport( ProgressKind.Status, $"Pack: {manifest.Name ?? "modpack"} v{manifest.Version ?? "?"} (local: {local?.Version ?? "none"})" )); var wantedPaths = new HashSet( manifest.Files.Select(f => NormalizePath(f.Path)), StringComparer.OrdinalIgnoreCase ); // Remove managed files no longer in manifest var toRemove = new List(); foreach (var root in ManagedRoots) { var rootDir = Path.Combine(installDir, root); if (!Directory.Exists(rootDir)) continue; foreach (var file in Directory.EnumerateFiles(rootDir, "*", SearchOption.AllDirectories)) { var rel = NormalizePath(Path.GetRelativePath(installDir, file)); if (!wantedPaths.Contains(rel)) { toRemove.Add(file); } } } foreach (var file in toRemove) { try { File.Delete(file); progress.Report(new ProgressReport( ProgressKind.Log, $"Removed: {Path.GetRelativePath(installDir, file)}" )); } catch (Exception ex) { progress.Report(new ProgressReport( ProgressKind.Log, $"Could not remove {file}: {ex.Message}" )); } } // Determine what to download var toDownload = new List(); foreach (var file in manifest.Files) { ct.ThrowIfCancellationRequested(); var dest = Path.Combine(installDir, file.Path); if (!File.Exists(dest)) { toDownload.Add(file); continue; } if (!string.IsNullOrEmpty(file.Sha1)) { var actual = await ComputeSha1Async(dest, ct).ConfigureAwait(false); if (!string.Equals(actual, file.Sha1, StringComparison.OrdinalIgnoreCase)) { toDownload.Add(file); } } } progress.Report(new ProgressReport( ProgressKind.Status, toDownload.Count == 0 ? "Already up-to-date." : $"Downloading {toDownload.Count} file(s)..." )); for (int i = 0; i < toDownload.Count; i++) { ct.ThrowIfCancellationRequested(); var file = toDownload[i]; var pct = toDownload.Count == 0 ? 100 : (i * 100.0 / toDownload.Count); progress.Report(new ProgressReport( ProgressKind.Progress, $"Downloading {file.Path}", Current: i + 1, Total: toDownload.Count, Percent: pct )); var dest = Path.Combine(installDir, file.Path); await DownloadFileAsync(file.Url, dest, file.Sha1, ct).ConfigureAwait(false); } var record = new PackVersionRecord { Name = manifest.Name, Version = manifest.Version, SyncedAt = DateTime.UtcNow.ToString("o") }; await File.WriteAllTextAsync( Path.Combine(installDir, PackVersionFile), JsonSerializer.Serialize(record, new JsonSerializerOptions { WriteIndented = true }), ct ).ConfigureAwait(false); progress.Report(new ProgressReport(ProgressKind.Status, "Sync complete.")); return new SyncResult(manifest, toDownload.Count, toRemove.Count); } private async Task DownloadFileAsync(string url, string destPath, string? expectedSha1, CancellationToken ct) { Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); var tmp = destPath + ".part"; using (var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false)) { response.EnsureSuccessStatusCode(); await using var src = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); await using var dst = File.Create(tmp); await src.CopyToAsync(dst, ct).ConfigureAwait(false); } if (!string.IsNullOrEmpty(expectedSha1)) { var actual = await ComputeSha1Async(tmp, ct).ConfigureAwait(false); if (!string.Equals(actual, expectedSha1, StringComparison.OrdinalIgnoreCase)) { File.Delete(tmp); throw new InvalidOperationException( $"Hash mismatch for {Path.GetFileName(destPath)} (expected {expectedSha1}, got {actual})" ); } } if (File.Exists(destPath)) File.Delete(destPath); File.Move(tmp, destPath); } private static async Task ComputeSha1Async(string path, CancellationToken ct) { using var sha = SHA1.Create(); await using var stream = File.OpenRead(path); var bytes = await sha.ComputeHashAsync(stream, ct).ConfigureAwait(false); return Convert.ToHexString(bytes).ToLowerInvariant(); } private static string NormalizePath(string p) => p.Replace('\\', '/').TrimStart('/'); }