using System; using System.Diagnostics; using System.IO; using System.Net.Http; using System.Threading.Tasks; using UnityEditor; using UnityEngine; namespace SplashEdit.EditorCode { /// /// Downloads and installs PCSX-Redux from the official distrib.app CDN. /// Mirrors the logic from pcsx-redux.js (the official download script). /// /// Flow: fetch platform manifest → find latest build ID → fetch build manifest → /// get download URL → download zip → extract to .tools/pcsx-redux/ /// public static class PCSXReduxDownloader { private const string MANIFEST_BASE = "https://distrib.app/storage/manifests/pcsx-redux/"; private static readonly HttpClient _http; static PCSXReduxDownloader() { var handler = new HttpClientHandler { AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate }; _http = new HttpClient(handler); _http.Timeout = TimeSpan.FromSeconds(60); _http.DefaultRequestHeaders.UserAgent.ParseAdd("SplashEdit/1.0"); } /// /// Returns the platform variant string for the current platform. /// private static string GetPlatformVariant() { switch (Application.platform) { case RuntimePlatform.WindowsEditor: return "dev-win-cli-x64"; case RuntimePlatform.LinuxEditor: return "dev-linux-x64"; default: return "dev-win-cli-x64"; } } /// /// Downloads and installs PCSX-Redux to .tools/pcsx-redux/. /// Shows progress bar during download. /// public static async Task DownloadAndInstall(Action log = null) { string variant = GetPlatformVariant(); log?.Invoke($"Platform variant: {variant}"); try { // Step 1: Fetch the master manifest to get the latest build ID string manifestUrl = $"{MANIFEST_BASE}{variant}/manifest.json"; log?.Invoke($"Fetching manifest: {manifestUrl}"); string manifestJson = await _http.GetStringAsync(manifestUrl); // Parse the latest build ID from the manifest. // The manifest is JSON with a "builds" array. We want the highest ID. // Simple JSON parsing without dependencies: int latestBuildId = ParseLatestBuildId(manifestJson); if (latestBuildId < 0) { log?.Invoke("Failed to parse build ID from manifest."); return false; } log?.Invoke($"Latest build ID: {latestBuildId}"); // Step 2: Fetch the specific build manifest string buildManifestUrl = $"{MANIFEST_BASE}{variant}/manifest-{latestBuildId}.json"; log?.Invoke($"Fetching build manifest..."); string buildManifestJson = await _http.GetStringAsync(buildManifestUrl); // Parse the download path string downloadPath = ParseDownloadPath(buildManifestJson); if (string.IsNullOrEmpty(downloadPath)) { log?.Invoke("Failed to parse download path from build manifest."); return false; } string downloadUrl = $"https://distrib.app{downloadPath}"; log?.Invoke($"Downloading: {downloadUrl}"); // Step 3: Download the file string tempFile = Path.Combine(Path.GetTempPath(), $"pcsx-redux-{latestBuildId}.zip"); EditorUtility.DisplayProgressBar("Downloading PCSX-Redux", "Downloading...", 0.1f); using (var response = await _http.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead)) { response.EnsureSuccessStatusCode(); long? totalBytes = response.Content.Headers.ContentLength; long downloadedBytes = 0; using (var fileStream = File.Create(tempFile)) using (var downloadStream = await response.Content.ReadAsStreamAsync()) { byte[] buffer = new byte[81920]; int bytesRead; while ((bytesRead = await downloadStream.ReadAsync(buffer, 0, buffer.Length)) > 0) { await fileStream.WriteAsync(buffer, 0, bytesRead); downloadedBytes += bytesRead; if (totalBytes.HasValue) { float progress = (float)downloadedBytes / totalBytes.Value; string sizeMB = $"{downloadedBytes / (1024 * 1024)}/{totalBytes.Value / (1024 * 1024)} MB"; EditorUtility.DisplayProgressBar("Downloading PCSX-Redux", $"Downloading... {sizeMB}", progress); } } } } log?.Invoke($"Downloaded to {tempFile}"); EditorUtility.DisplayProgressBar("Installing PCSX-Redux", "Extracting...", 0.9f); // Step 4: Extract string installDir = SplashBuildPaths.PCSXReduxDir; if (Directory.Exists(installDir)) Directory.Delete(installDir, true); Directory.CreateDirectory(installDir); if (Application.platform == RuntimePlatform.LinuxEditor && tempFile.EndsWith(".tar.gz")) { var psi = new ProcessStartInfo { FileName = "tar", Arguments = $"xzf \"{tempFile}\" -C \"{installDir}\" --strip-components=1", UseShellExecute = false, CreateNoWindow = true }; var proc = Process.Start(psi); proc?.WaitForExit(); } else { System.IO.Compression.ZipFile.ExtractToDirectory(tempFile, installDir); log?.Invoke($"Extracted to {installDir}"); } // Clean up temp file try { File.Delete(tempFile); } catch { } // Step 5: Verify if (SplashBuildPaths.IsPCSXReduxInstalled()) { log?.Invoke("PCSX-Redux installed successfully!"); EditorUtility.ClearProgressBar(); return true; } else { // The zip might have a nested directory — try to find the exe SplashEdit.RuntimeCode.Utils.FixNestedDirectory(installDir); if (SplashBuildPaths.IsPCSXReduxInstalled()) { log?.Invoke("PCSX-Redux installed successfully!"); EditorUtility.ClearProgressBar(); return true; } log?.Invoke("Installation completed but PCSX-Redux binary not found at expected path."); log?.Invoke($"Expected: {SplashBuildPaths.PCSXReduxBinary}"); log?.Invoke($"Check: {installDir}"); EditorUtility.ClearProgressBar(); return false; } } catch (Exception ex) { log?.Invoke($"Download failed: {ex.Message}"); EditorUtility.ClearProgressBar(); return false; } } /// /// Parse the latest build ID from the master manifest JSON. /// Expected format: {"builds":[{"id":1234,...},...],...} /// distrib.app returns builds sorted newest-first, so we take the first. /// Falls back to scanning all IDs if the "builds" section isn't found. /// private static int ParseLatestBuildId(string json) { // Fast path: find the first "id" inside "builds" array int buildsIdx = json.IndexOf("\"builds\"", StringComparison.Ordinal); int startPos = buildsIdx >= 0 ? buildsIdx : 0; string searchToken = "\"id\":"; int idx = json.IndexOf(searchToken, startPos, StringComparison.Ordinal); if (idx < 0) return -1; int pos = idx + searchToken.Length; while (pos < json.Length && char.IsWhiteSpace(json[pos])) pos++; int numStart = pos; while (pos < json.Length && char.IsDigit(json[pos])) pos++; if (pos > numStart && int.TryParse(json.Substring(numStart, pos - numStart), out int id)) return id; return -1; } /// /// Parse the download path from a build-specific manifest. /// Expected format: {...,"path":"/storage/builds/..."} /// private static string ParseDownloadPath(string json) { string searchToken = "\"path\":"; int idx = json.IndexOf(searchToken, StringComparison.Ordinal); if (idx < 0) return null; int pos = idx + searchToken.Length; while (pos < json.Length && char.IsWhiteSpace(json[pos])) pos++; if (pos >= json.Length || json[pos] != '"') return null; pos++; // skip opening quote int pathStart = pos; while (pos < json.Length && json[pos] != '"') pos++; return json.Substring(pathStart, pos - pathStart); } } }