diff --git a/Editor/Core/SplashControlPanel.cs b/Editor/Core/SplashControlPanel.cs index 2b920a6..92945b5 100644 --- a/Editor/Core/SplashControlPanel.cs +++ b/Editor/Core/SplashControlPanel.cs @@ -60,6 +60,13 @@ namespace SplashEdit.EditorCode private string _nativeInstallStatus = ""; private string _manualNativePath = ""; + // ───── Release selector ───── + private int _selectedReleaseIndex = 0; + private string[] _releaseDisplayNames = new string[0]; + private bool _isFetchingReleases; + private string _currentTag = ""; + private bool _isSwitchingRelease; + // PCdrv serial host instance (for real hardware file serving) private static PCdrvSerialHost _pcdrvHost; @@ -89,6 +96,8 @@ namespace SplashEdit.EditorCode RefreshToolchainStatus(); LoadSceneList(); _manualNativePath = SplashSettings.NativeProjectPath; + FetchGitHubReleases(); + RefreshCurrentTag(); } private void OnDisable() @@ -215,44 +224,106 @@ namespace SplashEdit.EditorCode } else { - GUILayout.Label("Not found — clone from GitHub or set path manually", EditorStyles.miniLabel); + GUILayout.Label("Not found — download from GitHub or set path manually", EditorStyles.miniLabel); } EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(6); - // ── Option 1: Auto-clone from GitHub ── + // ── Option 1: Download a release from GitHub ── PSXEditorStyles.DrawSeparator(4, 4); - GUILayout.Label("Clone from GitHub", PSXEditorStyles.SectionHeader); - EditorGUILayout.BeginHorizontal(); - GUILayout.Label(PSXSplashInstaller.RepoUrl, EditorStyles.miniLabel); - GUILayout.FlexibleSpace(); - EditorGUI.BeginDisabledGroup(_isInstallingNative || PSXSplashInstaller.IsInstalled()); - if (GUILayout.Button(PSXSplashInstaller.IsInstalled() ? "Installed" : "Clone", GUILayout.Width(80))) + GUILayout.Label("Download from GitHub", PSXEditorStyles.SectionHeader); + + // Git availability check + if (!PSXSplashInstaller.IsGitAvailable()) { - CloneNativeProject(); + EditorGUILayout.HelpBox( + "git is required to download the native project (submodules need recursive clone).\n" + + "Install git from: https://git-scm.com/downloads", + MessageType.Warning); + } + + // Release selector + EditorGUILayout.BeginHorizontal(); + GUILayout.Label("Release:", GUILayout.Width(55)); + if (_isFetchingReleases) + { + GUILayout.Label("Fetching releases...", EditorStyles.miniLabel); + } + else if (_releaseDisplayNames.Length == 0) + { + GUILayout.Label("No releases found", EditorStyles.miniLabel); + if (GUILayout.Button("Refresh", EditorStyles.miniButton, GUILayout.Width(60))) + FetchGitHubReleases(); + } + else + { + _selectedReleaseIndex = EditorGUILayout.Popup(_selectedReleaseIndex, _releaseDisplayNames); + if (GUILayout.Button("↻", EditorStyles.miniButton, GUILayout.Width(22))) + FetchGitHubReleases(); } - EditorGUI.EndDisabledGroup(); EditorGUILayout.EndHorizontal(); - if (_isInstallingNative) + // Current version display (when installed) + if (PSXSplashInstaller.IsInstalled() && !string.IsNullOrEmpty(_currentTag)) { - GUILayout.Label(_nativeInstallStatus, PSXEditorStyles.InfoBox); + var prevColor = GUI.contentColor; + GUI.contentColor = PSXEditorStyles.Success; + GUILayout.Label($"Current version: {_currentTag}", EditorStyles.miniLabel); + GUI.contentColor = prevColor; } - // If already cloned, show version management - if (PSXSplashInstaller.IsInstalled()) + // Clone / Switch / Open buttons + EditorGUILayout.BeginHorizontal(); + if (!PSXSplashInstaller.IsInstalled()) { - EditorGUILayout.BeginHorizontal(); - if (GUILayout.Button("Fetch Latest", EditorStyles.miniButton, GUILayout.Width(90))) + // Not installed yet — show Clone button + EditorGUI.BeginDisabledGroup( + _isInstallingNative || _releaseDisplayNames.Length == 0 || + !PSXSplashInstaller.IsGitAvailable()); + if (GUILayout.Button("Download Release", GUILayout.Width(130))) { - FetchNativeLatest(); + CloneNativeProject(); } + EditorGUI.EndDisabledGroup(); + } + else + { + // Already installed — show Switch and Open buttons + EditorGUI.BeginDisabledGroup( + _isSwitchingRelease || _isInstallingNative || + _releaseDisplayNames.Length == 0 || !PSXSplashInstaller.IsGitAvailable()); + if (GUILayout.Button("Switch Release", EditorStyles.miniButton, GUILayout.Width(100))) + { + SwitchNativeRelease(); + } + EditorGUI.EndDisabledGroup(); + if (GUILayout.Button("Open Folder", EditorStyles.miniButton, GUILayout.Width(90))) { EditorUtility.RevealInFinder(PSXSplashInstaller.FullInstallPath); } - EditorGUILayout.EndHorizontal(); + } + EditorGUILayout.EndHorizontal(); + + // Progress / status message + if (_isInstallingNative || _isSwitchingRelease) + { + GUILayout.Label(_nativeInstallStatus, PSXEditorStyles.InfoBox); + } + + // Show release notes for selected release + if (_releaseDisplayNames.Length > 0 && _selectedReleaseIndex < PSXSplashInstaller.CachedReleases.Count) + { + var selected = PSXSplashInstaller.CachedReleases[_selectedReleaseIndex]; + if (!string.IsNullOrEmpty(selected.Body)) + { + EditorGUILayout.Space(2); + string trimmedNotes = selected.Body.Length > 200 + ? selected.Body.Substring(0, 200) + "..." + : selected.Body; + GUILayout.Label(trimmedNotes, EditorStyles.wordWrappedMiniLabel); + } } EditorGUILayout.Space(6); @@ -1840,34 +1911,99 @@ namespace SplashEdit.EditorCode } } - // ───── Native Project Clone/Fetch ───── + // ───── Release fetching & management ───── - private async void CloneNativeProject() + private async void FetchGitHubReleases() { - _isInstallingNative = true; - _nativeInstallStatus = "Cloning psxsplash repository (this may take a minute)..."; + _isFetchingReleases = true; Repaint(); - Log("Cloning psxsplash native project from GitHub...", LogType.Log); - try { - bool success = await PSXSplashInstaller.Install(); - if (success) + var releases = await PSXSplashInstaller.FetchReleasesAsync(); + if (releases.Count > 0) { - Log("psxsplash cloned successfully!", LogType.Log); - _nativeInstallStatus = ""; - RefreshToolchainStatus(); + _releaseDisplayNames = releases + .Select(r => + { + string label = r.TagName; + if (!string.IsNullOrEmpty(r.Name) && r.Name != r.TagName) + label += $" — {r.Name}"; + if (r.IsPrerelease) + label += " (pre-release)"; + return label; + }) + .ToArray(); + + // Try to select the currently checked-out tag + if (!string.IsNullOrEmpty(_currentTag)) + { + int idx = releases.FindIndex(r => r.TagName == _currentTag); + if (idx >= 0) _selectedReleaseIndex = idx; + } + + _selectedReleaseIndex = Mathf.Clamp(_selectedReleaseIndex, 0, _releaseDisplayNames.Length - 1); } else { - Log("Clone failed. Check console for errors.", LogType.Error); - _nativeInstallStatus = "Clone failed — check console for details."; + _releaseDisplayNames = new string[0]; } } catch (Exception ex) { - Log($"Clone error: {ex.Message}", LogType.Error); + Log($"Failed to fetch releases: {ex.Message}", LogType.Warning); + } + finally + { + _isFetchingReleases = false; + Repaint(); + } + } + + private void RefreshCurrentTag() + { + _currentTag = PSXSplashInstaller.GetCurrentTag() ?? ""; + } + + // ───── Native Project Clone/Switch ───── + + private async void CloneNativeProject() + { + if (_selectedReleaseIndex < 0 || _selectedReleaseIndex >= PSXSplashInstaller.CachedReleases.Count) + return; + + string tag = PSXSplashInstaller.CachedReleases[_selectedReleaseIndex].TagName; + + _isInstallingNative = true; + _nativeInstallStatus = $"Downloading psxsplash {tag} (this may take a minute)..."; + Repaint(); + + Log($"Downloading psxsplash {tag} from GitHub...", LogType.Log); + + try + { + bool success = await PSXSplashInstaller.InstallRelease(tag, msg => + { + _nativeInstallStatus = msg; + Repaint(); + }); + + if (success) + { + Log($"psxsplash {tag} downloaded successfully!", LogType.Log); + _nativeInstallStatus = ""; + RefreshToolchainStatus(); + RefreshCurrentTag(); + } + else + { + Log("Download failed. Check console for errors.", LogType.Error); + _nativeInstallStatus = "Download failed — check console for details."; + } + } + catch (Exception ex) + { + Log($"Download error: {ex.Message}", LogType.Error); _nativeInstallStatus = $"Error: {ex.Message}"; } finally @@ -1877,22 +2013,55 @@ namespace SplashEdit.EditorCode } } - private async void FetchNativeLatest() + private async void SwitchNativeRelease() { - Log("Fetching latest changes...", LogType.Log); + if (_selectedReleaseIndex < 0 || _selectedReleaseIndex >= PSXSplashInstaller.CachedReleases.Count) + return; + + string tag = PSXSplashInstaller.CachedReleases[_selectedReleaseIndex].TagName; + + if (tag == _currentTag) + { + Log($"Already on {tag}.", LogType.Log); + return; + } + + _isSwitchingRelease = true; + _nativeInstallStatus = $"Switching to {tag}..."; + Repaint(); + + Log($"Switching native project to {tag}...", LogType.Log); + try { - bool success = await PSXSplashInstaller.FetchLatestAsync(); + bool success = await PSXSplashInstaller.SwitchToReleaseAsync(tag, msg => + { + _nativeInstallStatus = msg; + Repaint(); + }); + if (success) - Log("Fetch complete. Use 'git pull' to apply updates.", LogType.Log); + { + Log($"Switched to {tag}.", LogType.Log); + _nativeInstallStatus = ""; + RefreshCurrentTag(); + } else - Log("Fetch failed.", LogType.Warning); + { + Log($"Failed to switch to {tag}.", LogType.Error); + _nativeInstallStatus = "Switch failed — check console for details."; + } } catch (Exception ex) { - Log($"Fetch error: {ex.Message}", LogType.Error); + Log($"Switch error: {ex.Message}", LogType.Error); + _nativeInstallStatus = $"Error: {ex.Message}"; + } + finally + { + _isSwitchingRelease = false; + Repaint(); } - Repaint(); } // ═══════════════════════════════════════════════════════════════ diff --git a/Editor/PSXSplashInstaller.cs b/Editor/PSXSplashInstaller.cs index 525c2a2..3aa09d3 100644 --- a/Editor/PSXSplashInstaller.cs +++ b/Editor/PSXSplashInstaller.cs @@ -1,149 +1,307 @@ using UnityEngine; +using UnityEngine.Networking; +using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; -using System.Collections.Generic; using System.Threading.Tasks; -using System; namespace SplashEdit.EditorCode { - + /// + /// Manages downloading and updating the psxsplash native project from GitHub releases. + /// Uses the GitHub REST API (HTTP) to list releases and git to clone/checkout + /// (required for recursive submodule support). + /// public static class PSXSplashInstaller { + // ───── Public config ───── + public static readonly string RepoOwner = "psxsplash"; + public static readonly string RepoName = "psxsplash"; public static readonly string RepoUrl = "https://github.com/psxsplash/psxsplash.git"; public static readonly string InstallPath = "Assets/psxsplash"; public static readonly string FullInstallPath; + private static readonly string GitHubApiReleasesUrl = + $"https://api.github.com/repos/{RepoOwner}/{RepoName}/releases"; + + // ───── Cached release list ───── + private static List _cachedReleases = new List(); + private static bool _isFetchingReleases; + + /// + /// Represents a GitHub release. + /// + [Serializable] + public class ReleaseInfo + { + public string TagName; // e.g. "v1.2.0" + public string Name; // human-readable name + public string Body; // release notes (markdown) + public string PublishedAt; // ISO 8601 date + public bool IsPrerelease; + public bool IsDraft; + } + static PSXSplashInstaller() { FullInstallPath = Path.Combine(Application.dataPath, "psxsplash"); } + // ═══════════════════════════════════════════════════════════════ + // Queries + // ═══════════════════════════════════════════════════════════════ + + /// Is the native project cloned on disk? public static bool IsInstalled() { return Directory.Exists(FullInstallPath) && Directory.EnumerateFileSystemEntries(FullInstallPath).Any(); } - public static async Task Install() - { - if (IsInstalled()) return true; + /// Are we currently fetching releases from GitHub? + public static bool IsFetchingReleases => _isFetchingReleases; + /// Cached list of releases (call FetchReleasesAsync to populate). + public static IReadOnlyList CachedReleases => _cachedReleases; + + /// + /// Returns the tag currently checked out, or null if unknown / not a git repo. + /// + public static string GetCurrentTag() + { + if (!IsInstalled()) return null; try { - // Create the parent directory if it doesn't exist - Directory.CreateDirectory(Application.dataPath); - - // Clone the repository - var result = await RunGitCommandAsync($"clone --recursive {RepoUrl} \"{FullInstallPath}\"", Application.dataPath); - return !result.Contains("error"); + string result = RunGitCommandSync("describe --tags --exact-match HEAD", FullInstallPath); + return string.IsNullOrWhiteSpace(result) ? null : result.Trim(); } - catch (Exception e) + catch { - UnityEngine.Debug.LogError($"Failed to install PSXSplash: {e.Message}"); - return false; + return null; } } - public static async Task> GetBranchesWithLatestCommitsAsync() - { - if (!IsInstalled()) return new Dictionary(); + // ═══════════════════════════════════════════════════════════════ + // Fetch Releases (HTTP — no git required) + // ═══════════════════════════════════════════════════════════════ + /// + /// Fetches the list of releases from the GitHub REST API. + /// Does NOT require git — uses UnityWebRequest. + /// + public static async Task> FetchReleasesAsync() + { + _isFetchingReleases = true; try { - // Fetch all branches and tags - await RunGitCommandAsync("fetch --all", FullInstallPath); - - // Get all remote branches - var branchesOutput = await RunGitCommandAsync("branch -r", FullInstallPath); - var branches = branchesOutput.Split('\n') - .Where(b => !string.IsNullOrEmpty(b.Trim())) - .Select(b => b.Trim().Replace("origin/", "")) - .Where(b => !b.Contains("HEAD")) - .ToList(); - - var branchesWithCommits = new Dictionary(); - - // Get the latest commit for each branch - foreach (var branch in branches) + string json = await HttpGetAsync(GitHubApiReleasesUrl); + if (string.IsNullOrEmpty(json)) { - var commitOutput = await RunGitCommandAsync($"log origin/{branch} -1 --pretty=format:%h", FullInstallPath); - if (!string.IsNullOrEmpty(commitOutput)) - { - branchesWithCommits[branch] = commitOutput.Trim(); - } + UnityEngine.Debug.LogWarning("[PSXSplashInstaller] Failed to fetch releases from GitHub."); + return _cachedReleases; } - return branchesWithCommits; - } - catch (Exception e) - { - UnityEngine.Debug.LogError($"Failed to get branches: {e.Message}"); - return new Dictionary(); - } - } - - public static async Task> GetReleasesAsync() - { - if (!IsInstalled()) return new List(); - - try - { - await RunGitCommandAsync("fetch --tags", FullInstallPath); - var output = await RunGitCommandAsync("tag -l", FullInstallPath); - - return output.Split('\n') - .Where(t => !string.IsNullOrEmpty(t.Trim())) - .Select(t => t.Trim()) + var releases = ParseReleasesJson(json); + // Filter out drafts, sort by newest first + releases = releases + .Where(r => !r.IsDraft) + .OrderByDescending(r => r.PublishedAt) .ToList(); + + _cachedReleases = releases; + return releases; } - catch (Exception e) + catch (Exception ex) { - UnityEngine.Debug.LogError($"Failed to get releases: {e.Message}"); - return new List(); + UnityEngine.Debug.LogError($"[PSXSplashInstaller] Error fetching releases: {ex.Message}"); + return _cachedReleases; + } + finally + { + _isFetchingReleases = false; } } - public static async Task CheckoutVersionAsync(string version) + // ═══════════════════════════════════════════════════════════════ + // Install / Clone at a specific release tag + // ═══════════════════════════════════════════════════════════════ + + /// + /// Clones the repository at the specified release tag with --recursive. + /// Uses a shallow clone (--depth 1) for speed. + /// Requires git to be installed (submodules cannot be fetched via HTTP archives). + /// + /// The release tag to clone, e.g. "v1.2.0". If null, clones the default branch. + /// Optional progress callback. + public static async Task InstallRelease(string tag, Action onProgress = null) { - if (!IsInstalled()) return false; + if (IsInstalled()) + { + onProgress?.Invoke("Already installed. Use SwitchToRelease to change version."); + return true; + } + + if (!IsGitAvailable()) + { + UnityEngine.Debug.LogError( + "[PSXSplashInstaller] git is required for recursive submodule clone but was not found on PATH.\n" + + "Please install git: https://git-scm.com/downloads"); + return false; + } try { - // If it's a branch name, checkout the branch - // If it's a commit hash, checkout the commit - var result = await RunGitCommandAsync($"checkout {version}", FullInstallPath); - var result2 = await RunGitCommandAsync("submodule update --init --recursive", FullInstallPath); + Directory.CreateDirectory(Path.GetDirectoryName(FullInstallPath)); - return !result.Contains("error") && !result2.Contains("error"); + string branchArg = string.IsNullOrEmpty(tag) ? "" : $"--branch {tag}"; + string cmd = $"clone --recursive --depth 1 {branchArg} {RepoUrl} \"{FullInstallPath}\""; + + onProgress?.Invoke($"Cloning {RepoUrl} at {tag ?? "HEAD"}..."); + string result = await RunGitCommandAsync(cmd, Application.dataPath, onProgress); + + if (!IsInstalled()) + { + UnityEngine.Debug.LogError("[PSXSplashInstaller] Clone completed but directory is empty."); + return false; + } + + onProgress?.Invoke("Clone complete."); + return true; } - catch (Exception e) + catch (Exception ex) { - UnityEngine.Debug.LogError($"Failed to checkout version: {e.Message}"); + UnityEngine.Debug.LogError($"[PSXSplashInstaller] Clone failed: {ex.Message}"); return false; } } + /// + /// Switches an existing clone to a different release tag. + /// Fetches tags, checks out the tag, and updates submodules recursively. + /// + public static async Task SwitchToReleaseAsync(string tag, Action onProgress = null) + { + if (!IsInstalled()) + { + UnityEngine.Debug.LogError("[PSXSplashInstaller] Not installed — clone first."); + return false; + } + + if (!IsGitAvailable()) + { + UnityEngine.Debug.LogError("[PSXSplashInstaller] git not found on PATH."); + return false; + } + + try + { + onProgress?.Invoke("Fetching tags..."); + await RunGitCommandAsync("fetch --tags --depth=1", FullInstallPath, onProgress); + await RunGitCommandAsync($"fetch origin tag {tag} --no-tags", FullInstallPath, onProgress); + + onProgress?.Invoke($"Checking out {tag}..."); + await RunGitCommandAsync($"checkout {tag}", FullInstallPath, onProgress); + + onProgress?.Invoke("Updating submodules..."); + await RunGitCommandAsync("submodule update --init --recursive", FullInstallPath, onProgress); + + onProgress?.Invoke($"Switched to {tag}."); + return true; + } + catch (Exception ex) + { + UnityEngine.Debug.LogError($"[PSXSplashInstaller] Switch failed: {ex.Message}"); + return false; + } + } + + /// + /// Legacy compatibility: Install without specifying a tag (clones default branch). + /// + public static Task Install() + { + return InstallRelease(null); + } + + /// + /// Fetches latest remote data (tags, branches). + /// Requires git. + /// public static async Task FetchLatestAsync() { if (!IsInstalled()) return false; try { - var result = await RunGitCommandAsync("fetch --all", FullInstallPath); - return !result.Contains("error"); + await RunGitCommandAsync("fetch --all --tags", FullInstallPath); + return true; } - catch (Exception e) + catch (Exception ex) { - UnityEngine.Debug.LogError($"Failed to fetch latest: {e.Message}"); + UnityEngine.Debug.LogError($"[PSXSplashInstaller] Fetch failed: {ex.Message}"); return false; } } - private static async Task RunGitCommandAsync(string arguments, string workingDirectory) + // ═══════════════════════════════════════════════════════════════ + // Git helpers + // ═══════════════════════════════════════════════════════════════ + + /// + /// Checks whether git is available on the system PATH. + /// + public static bool IsGitAvailable() { - var processInfo = new ProcessStartInfo + try + { + var psi = new ProcessStartInfo + { + FileName = "git", + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using (var p = Process.Start(psi)) + { + p.WaitForExit(5000); + return p.ExitCode == 0; + } + } + catch + { + return false; + } + } + + private static string RunGitCommandSync(string arguments, string workingDirectory) + { + var psi = new ProcessStartInfo + { + FileName = "git", + Arguments = arguments, + WorkingDirectory = workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using (var process = Process.Start(psi)) + { + string output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(10000); + return output; + } + } + + private static async Task RunGitCommandAsync( + string arguments, string workingDirectory, Action onProgress = null) + { + var psi = new ProcessStartInfo { FileName = "git", Arguments = arguments, @@ -156,47 +314,136 @@ namespace SplashEdit.EditorCode using (var process = new Process()) { - process.StartInfo = processInfo; - var outputBuilder = new System.Text.StringBuilder(); - var errorBuilder = new System.Text.StringBuilder(); + process.StartInfo = psi; + process.EnableRaisingEvents = true; - process.OutputDataReceived += (sender, e) => + var stdout = new System.Text.StringBuilder(); + var stderr = new System.Text.StringBuilder(); + + process.OutputDataReceived += (s, e) => { if (!string.IsNullOrEmpty(e.Data)) - outputBuilder.AppendLine(e.Data); + { + stdout.AppendLine(e.Data); + onProgress?.Invoke(e.Data); + } }; - - process.ErrorDataReceived += (sender, e) => + process.ErrorDataReceived += (s, e) => { if (!string.IsNullOrEmpty(e.Data)) - errorBuilder.AppendLine(e.Data); + { + stderr.AppendLine(e.Data); + // git writes progress to stderr (clone progress, etc.) + onProgress?.Invoke(e.Data); + } }; + var tcs = new TaskCompletionSource(); + process.Exited += (s, e) => tcs.TrySetResult(process.ExitCode); + process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); - var timeout = TimeSpan.FromMinutes(10); - if (await Task.Run(() => process.WaitForExit((int)timeout.TotalMilliseconds))) + var timeoutTask = Task.Delay(TimeSpan.FromMinutes(10)); + var completedTask = await Task.WhenAny(tcs.Task, timeoutTask); + + if (completedTask == timeoutTask) { - process.WaitForExit(); // Ensure all output is processed - - string output = outputBuilder.ToString(); - string error = errorBuilder.ToString(); - - if (!string.IsNullOrEmpty(error)) - { - UnityEngine.Debug.LogError($"Git error: {error}"); - } - - return output; + try { process.Kill(); } catch { } + throw new TimeoutException("Git command timed out after 10 minutes."); } + + int exitCode = await tcs.Task; + process.Dispose(); + + string output = stdout.ToString(); + string error = stderr.ToString(); + + if (exitCode != 0) + { + UnityEngine.Debug.LogError($"[git {arguments}] exit code {exitCode}\n{error}"); + } + + return output + error; + } + } + + // ═══════════════════════════════════════════════════════════════ + // HTTP helpers (no git needed) + // ═══════════════════════════════════════════════════════════════ + + private static Task HttpGetAsync(string url) + { + var tcs = new TaskCompletionSource(); + var request = UnityWebRequest.Get(url); + request.SetRequestHeader("User-Agent", "SplashEdit-Unity"); + request.SetRequestHeader("Accept", "application/vnd.github.v3+json"); + + var op = request.SendWebRequest(); + op.completed += _ => + { + if (request.result == UnityWebRequest.Result.Success) + tcs.TrySetResult(request.downloadHandler.text); else { - process.Kill(); - throw new TimeoutException("Git command timed out"); + UnityEngine.Debug.LogWarning($"[PSXSplashInstaller] HTTP GET {url} failed: {request.error}"); + tcs.TrySetResult(null); } + request.Dispose(); + }; + + return tcs.Task; + } + + // ═══════════════════════════════════════════════════════════════ + // JSON parsing (minimal, avoids external dependency) + // ═══════════════════════════════════════════════════════════════ + + /// + /// Minimal JSON parser for the GitHub releases API response. + /// Uses Unity's JsonUtility via a wrapper since it can't parse top-level arrays. + /// + private static List ParseReleasesJson(string json) + { + var releases = new List(); + + string wrapped = "{\"items\":" + json + "}"; + var wrapper = JsonUtility.FromJson(wrapped); + + if (wrapper?.items == null) return releases; + + foreach (var item in wrapper.items) + { + releases.Add(new ReleaseInfo + { + TagName = item.tag_name ?? "", + Name = item.name ?? item.tag_name ?? "", + Body = item.body ?? "", + PublishedAt = item.published_at ?? "", + IsPrerelease = item.prerelease, + IsDraft = item.draft + }); } + + return releases; + } + + [Serializable] + private class GitHubReleaseArrayWrapper + { + public GitHubReleaseJson[] items; + } + + [Serializable] + private class GitHubReleaseJson + { + public string tag_name; + public string name; + public string body; + public string published_at; + public bool prerelease; + public bool draft; } } } \ No newline at end of file