Initial commit: Client Doc docs Server Tools
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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.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<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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user