using System; using System.Collections; using System.IO; using UnityEngine; using UnityEngine.Networking; namespace XGame.MiniGame { // 下载并校验某小游戏版本的全部文件;产出本地目录 + 解析好的 manifest public sealed class MiniGameDownloader { public string LocalDir { get; private set; } public MiniGameManifest Manifest { get; private set; } public string Error { get; private set; } private readonly string _cdnBase; // 例如 http://.../minigame/ private readonly string _gameId; private readonly int _version; public MiniGameDownloader(string cdnBase, string gameId, int version) { _cdnBase = NormalizeCdnBase(cdnBase); _gameId = gameId; _version = version; LocalDir = $"{Application.persistentDataPath}/minigame/{gameId}/{version}/"; } private string RemoteBase => $"{_cdnBase}{_gameId}/{_version}/{MiniGamePlatform.Name}/"; public IEnumerator Run(Action onDone) { Error = null; Directory.CreateDirectory(LocalDir); // 1) game.json(每次拉取,文件小) string gameJson = null; yield return GetText($"{RemoteBase}game.json", t => gameJson = t); if (gameJson == null) { Fail("下载 game.json 失败"); onDone(false); yield break; } try { Manifest = MiniGameManifest.ParseGameJson(gameJson); } catch (Exception e) { Fail("game.json 解析失败: " + e.Message); onDone(false); yield break; } if (Manifest.GameId != _gameId || Manifest.Version != _version) { Fail("game.json 与请求的 gameId/version 不符"); onDone(false); yield break; } if (Manifest.MinFrameworkVersion > XWorld.Framework.FrameworkInfo.Version) { Fail($"小游戏要求最小框架版本 {Manifest.MinFrameworkVersion},当前框架 {XWorld.Framework.FrameworkInfo.Version}"); onDone(false); yield break; } // 2) files.txt(md5 清单) string filesTxt = null; yield return GetText($"{RemoteBase}files.txt", t => filesTxt = t); if (filesTxt == null) { Fail("下载 files.txt 失败"); onDone(false); yield break; } Manifest.LoadFilesList(filesTxt); // 验签(设计 §7):下载 files.txt.sig,校验清单签名后才信任 md5 列表 byte[] sig = null; yield return GetBytes($"{RemoteBase}files.txt.sig", b => sig = b); if (ClientManifestVerifier.IsConfigured) { if (sig == null) { Fail("缺少签名文件 files.txt.sig"); onDone(false); yield break; } if (!ClientManifestVerifier.Verify(Manifest.Digest(), sig)) { Fail("清单签名验证失败,拒绝加载"); onDone(false); yield break; } } // 3) 按清单逐个增量下载(md5 命名;本地已存在且 md5 匹配则跳过) foreach (var kv in Manifest.Files) { string name = kv.Key; string md5 = kv.Value.Md5; string localPath = LocalDir + name; if (File.Exists(localPath) && Md5OfFile(localPath) == md5) continue; string remote = $"{RemoteBase}{Md5Name(name, md5)}"; byte[] data = null; yield return GetBytes(remote, b => data = b); if (data == null) { Fail("下载失败: " + remote); onDone(false); yield break; } if (Md5OfBytes(data) != md5) { Fail("md5 不符: " + name); onDone(false); yield break; } Directory.CreateDirectory(Path.GetDirectoryName(localPath)); File.WriteAllBytes(localPath, data); } onDone(true); } private void Fail(string msg) { Error = msg; Debug.LogError("[MiniGameDownloader] " + msg); } private static string NormalizeCdnBase(string cdnBase) { string url = string.IsNullOrWhiteSpace(cdnBase) ? "http://127.0.0.1:15081/" : cdnBase.Trim(); if (url.StartsWith("http://file://", StringComparison.OrdinalIgnoreCase)) { url = url.Substring("http://".Length); } else if (url.StartsWith("https://file://", StringComparison.OrdinalIgnoreCase)) { url = url.Substring("https://".Length); } if (url.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) { url = NormalizeFileUrl(url); } else if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { url = "http://" + url; } return url.EndsWith("/") ? url : url + "/"; } private static string NormalizeFileUrl(string url) { string rawPath = url.Substring("file://".Length).Replace('\\', '/'); while (rawPath.StartsWith("/") && rawPath.Length > 2 && rawPath[2] == ':') { rawPath = rawPath.Substring(1); } string localPath = Uri.UnescapeDataString(rawPath).Replace('/', Path.DirectorySeparatorChar); try { return new Uri(localPath).AbsoluteUri; } catch { return "file:///" + rawPath.Replace("#", "%23"); } } // 与 LoadDll.GetFileMd5Name 同款:name.ext -> name_md5.ext private static string Md5Name(string filename, string md5) { int pos = filename.LastIndexOf('.'); return pos >= 0 ? $"{filename.Substring(0, pos)}_{md5}{filename.Substring(pos)}" : $"{filename}_{md5}"; } private static string Md5OfBytes(byte[] data) { using (var md5 = System.Security.Cryptography.MD5.Create()) { var hash = md5.ComputeHash(data); var sb = new System.Text.StringBuilder(hash.Length * 2); foreach (var b in hash) sb.Append(b.ToString("x2")); return sb.ToString(); } } private static string Md5OfFile(string path) => Md5OfBytes(File.ReadAllBytes(path)); private IEnumerator GetText(string url, Action onText) { using (var uwr = UnityWebRequest.Get(url)) { yield return uwr.SendWebRequest(); onText(uwr.result == UnityWebRequest.Result.Success ? uwr.downloadHandler.text : null); if (uwr.result != UnityWebRequest.Result.Success) Debug.LogError($"[MiniGameDownloader] GET 失败 {url}: {uwr.error}"); } } private IEnumerator GetBytes(string url, Action onBytes) { using (var uwr = UnityWebRequest.Get(url)) { yield return uwr.SendWebRequest(); onBytes(uwr.result == UnityWebRequest.Result.Success ? uwr.downloadHandler.data : null); if (uwr.result != UnityWebRequest.Result.Success) Debug.LogError($"[MiniGameDownloader] GET 失败 {url}: {uwr.error}"); } } } }