Initial commit: Client Doc docs Server Tools

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ud18010
2026-07-10 10:24:29 +08:00
co-authored by Cursor
commit 7e35d8da31
3374 changed files with 680813 additions and 0 deletions
@@ -0,0 +1,168 @@
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<bool> 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.txtmd5 清单)
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<string> 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<byte[]> 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}");
}
}
}
}