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);
}
}
}