Initial commit: Client Doc docs Server Tools
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
|
||||
namespace XWorld.PublishTool
|
||||
{
|
||||
// PublishTool.Cli 的参数:--mode client|server --src <dir> --out <dir> --spec <json> [--key <pem>]
|
||||
public sealed class CliOptions
|
||||
{
|
||||
public string Mode; // "client" | "server"
|
||||
public string Src; // 构建产物目录
|
||||
public string Out; // 落位目录(版本目录本体)
|
||||
public string SpecPath; // resolved-spec.json
|
||||
public string KeyPath; // 私钥 pem,可空=不签名
|
||||
|
||||
public const string Usage =
|
||||
"用法: PublishTool.Cli --mode client|server --src <dir> --out <dir> --spec <resolved-spec.json> [--key <private.pem>]";
|
||||
|
||||
public static CliOptions Parse(string[] args)
|
||||
{
|
||||
var o = new CliOptions();
|
||||
for (int i = 0; i < args.Length; i += 2)
|
||||
{
|
||||
if (i + 1 >= args.Length) throw new ArgumentException($"缺少 {args[i]} 的值\n{Usage}");
|
||||
string v = args[i + 1];
|
||||
switch (args[i])
|
||||
{
|
||||
case "--mode": o.Mode = v; break;
|
||||
case "--src": o.Src = v; break;
|
||||
case "--out": o.Out = v; break;
|
||||
case "--spec": o.SpecPath = v; break;
|
||||
case "--key": o.KeyPath = v; break;
|
||||
default: throw new ArgumentException($"未知参数 {args[i]}\n{Usage}");
|
||||
}
|
||||
}
|
||||
if (o.Mode != "client" && o.Mode != "server")
|
||||
throw new ArgumentException($"--mode 必须是 client 或 server\n{Usage}");
|
||||
if (string.IsNullOrEmpty(o.Src) || string.IsNullOrEmpty(o.Out) || string.IsNullOrEmpty(o.SpecPath))
|
||||
throw new ArgumentException($"--src/--out/--spec 均必填\n{Usage}");
|
||||
return o;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace XWorld.PublishTool
|
||||
{
|
||||
public sealed class FilesManifest
|
||||
{
|
||||
public sealed class Entry { public string Md5; public int Size; }
|
||||
public Dictionary<string, Entry> Entries { get; } = new Dictionary<string, Entry>();
|
||||
|
||||
public void Add(string name, string md5, int size) => Entries[name] = new Entry { Md5 = md5, Size = size };
|
||||
|
||||
// 行格式沿用 dllfiles:{'name','md5',size},\n(按 name 排序,保证确定性)
|
||||
public string Render()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var kv in Entries.OrderBy(e => e.Key, System.StringComparer.Ordinal))
|
||||
sb.Append("{'").Append(kv.Key).Append("','").Append(kv.Value.Md5).Append("',").Append(kv.Value.Size).Append("},\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public static FilesManifest Parse(string text)
|
||||
{
|
||||
var m = new FilesManifest();
|
||||
if (string.IsNullOrEmpty(text)) return m;
|
||||
foreach (var raw in text.Split('\n'))
|
||||
{
|
||||
string line = raw.Trim();
|
||||
int lb = line.IndexOf('{'); int rb = line.LastIndexOf('}');
|
||||
if (lb < 0 || rb <= lb) continue;
|
||||
string inner = line.Substring(lb + 1, rb - lb - 1).Replace("'", "");
|
||||
string[] p = inner.Split(',');
|
||||
if (p.Length < 3) continue;
|
||||
int.TryParse(p[2].Trim(), out int size);
|
||||
m.Add(p[0].Trim(), p[1].Trim(), size);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// 稳定摘要:排序后的 name+md5 串(用于签名)
|
||||
public string Digest()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var kv in Entries.OrderBy(e => e.Key, System.StringComparer.Ordinal))
|
||||
sb.Append(kv.Key).Append('=').Append(kv.Value.Md5).Append(';');
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace XWorld.PublishTool
|
||||
{
|
||||
public sealed class GameSpec
|
||||
{
|
||||
public string GameId; public int Version; public int MinFrameworkVersion;
|
||||
public int PlayerCount; public int TickRateHz;
|
||||
public string ServerAssembly; public string ServerEntryType;
|
||||
public string CoreDll; public string ClientDll; public string ClientEntryType;
|
||||
public string CodeAb; // 代码 AB 文件名(统一 AB:DLL .bytes 打进它)
|
||||
public List<string> Assets = new List<string>(); // 资源 AB 完整文件名列表
|
||||
}
|
||||
|
||||
public static class GameJsonWriter
|
||||
{
|
||||
private static readonly JsonSerializerOptions Opts = new JsonSerializerOptions { WriteIndented = true };
|
||||
|
||||
public static string WriteServer(GameSpec s) => JsonSerializer.Serialize(new
|
||||
{
|
||||
gameId = s.GameId, version = s.Version, serverAssembly = s.ServerAssembly,
|
||||
serverEntryType = s.ServerEntryType, minFrameworkVersion = s.MinFrameworkVersion,
|
||||
playerCount = s.PlayerCount, tickRateHz = s.TickRateHz
|
||||
}, Opts);
|
||||
|
||||
public static string WriteClient(GameSpec s) => JsonSerializer.Serialize(new
|
||||
{
|
||||
gameId = s.GameId, version = s.Version, clientEntryType = s.ClientEntryType,
|
||||
coreDll = s.CoreDll, clientDll = s.ClientDll, minFrameworkVersion = s.MinFrameworkVersion,
|
||||
codeAb = s.CodeAb, assets = s.Assets
|
||||
}, Opts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace XWorld.PublishTool
|
||||
{
|
||||
// resolved-spec.json(Editor 侧 JsonUtility 产出,字段名小写)→ GameSpec。
|
||||
// GameSpec 用 public 字段,故必须 IncludeFields;键名大小写不敏感。
|
||||
public static class GameSpecJson
|
||||
{
|
||||
private static readonly JsonSerializerOptions Opts = new JsonSerializerOptions
|
||||
{
|
||||
IncludeFields = true,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
public static GameSpec Parse(string json)
|
||||
{
|
||||
var s = JsonSerializer.Deserialize<GameSpec>(json, Opts);
|
||||
if (s.Assets == null) s.Assets = new System.Collections.Generic.List<string>();
|
||||
return s;
|
||||
}
|
||||
|
||||
public static GameSpec ParseFile(string path) => Parse(File.ReadAllText(path));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace XWorld.PublishTool
|
||||
{
|
||||
public static class ManifestSigner
|
||||
{
|
||||
public static byte[] Sign(string content, string privateKeyPem)
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(privateKeyPem);
|
||||
return rsa.SignData(Encoding.UTF8.GetBytes(content), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
|
||||
public static bool Verify(string content, byte[] signature, string publicKeyPem)
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(publicKeyPem);
|
||||
return rsa.VerifyData(Encoding.UTF8.GetBytes(content), signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace XWorld.PublishTool
|
||||
{
|
||||
public static class Md5Util
|
||||
{
|
||||
public static string OfBytes(byte[] data)
|
||||
{
|
||||
using (var md5 = MD5.Create())
|
||||
{
|
||||
byte[] h = md5.ComputeHash(data ?? Array.Empty<byte>());
|
||||
var sb = new StringBuilder(h.Length * 2);
|
||||
foreach (var b in h) sb.Append(b.ToString("x2"));
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
public static string OfFile(string path) => OfBytes(File.ReadAllBytes(path));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace XWorld.PublishTool
|
||||
{
|
||||
// 把构建产物落成发布目录 + 清单 + (可选)签名。
|
||||
// privateKeyPem 传 null = 不签名:files.txt.sig 落空文件(两端验签均为"未配置则跳过")。
|
||||
public sealed class PublishLayout
|
||||
{
|
||||
// 服务器树:outDir 即 <gamesRoot>/<id>/<ver>/ 的内容(普通文件名,不做 md5 命名)
|
||||
public void PublishServer(string srcDir, string outDir, GameSpec spec, string privateKeyPem)
|
||||
{
|
||||
Directory.CreateDirectory(outDir);
|
||||
CopyTo(srcDir, spec.ServerAssembly, outDir);
|
||||
CopyTo(srcDir, spec.CoreDll, outDir);
|
||||
// game.json 先写,确保其 md5 纳入清单
|
||||
File.WriteAllText(Path.Combine(outDir, "game.json"), GameJsonWriter.WriteServer(spec));
|
||||
|
||||
var fm = new FilesManifest();
|
||||
foreach (var name in new[] { spec.ServerAssembly, spec.CoreDll, "game.json" })
|
||||
{
|
||||
byte[] b = File.ReadAllBytes(Path.Combine(outDir, name));
|
||||
fm.Add(name, Md5Util.OfBytes(b), b.Length);
|
||||
}
|
||||
WriteManifestAndSig(outDir, fm, privateKeyPem);
|
||||
}
|
||||
|
||||
// 客户端树:outDir 即 CDN/minigame/<id>/<ver>/<platform>/ 的内容。
|
||||
// spec.CodeAb / spec.Assets 均为完整 AB 文件名(code.unity3d / res.unity3d),统一 AB:无散 .bytes。
|
||||
public void PublishClient(string srcDir, string outDir, GameSpec spec, string privateKeyPem)
|
||||
{
|
||||
if (string.IsNullOrEmpty(spec.CodeAb))
|
||||
throw new InvalidOperationException("spec.CodeAb 为空:客户端发布需要代码 AB(统一 AB 格式)");
|
||||
Directory.CreateDirectory(outDir);
|
||||
|
||||
var fm = new FilesManifest();
|
||||
CopyMd5Named(srcDir, spec.CodeAb, outDir, fm);
|
||||
foreach (var ab in spec.Assets) CopyMd5Named(srcDir, ab, outDir, fm);
|
||||
|
||||
File.WriteAllText(Path.Combine(outDir, "game.json"), GameJsonWriter.WriteClient(spec));
|
||||
WriteManifestAndSig(outDir, fm, privateKeyPem);
|
||||
}
|
||||
|
||||
private static void WriteManifestAndSig(string outDir, FilesManifest fm, string privateKeyPem)
|
||||
{
|
||||
File.WriteAllText(Path.Combine(outDir, "files.txt"), fm.Render());
|
||||
byte[] sig = privateKeyPem == null
|
||||
? Array.Empty<byte>()
|
||||
: ManifestSigner.Sign(fm.Digest(), privateKeyPem);
|
||||
File.WriteAllBytes(Path.Combine(outDir, "files.txt.sig"), sig);
|
||||
}
|
||||
|
||||
private static void CopyTo(string srcDir, string name, string dstDir)
|
||||
=> File.Copy(Path.Combine(srcDir, name), Path.Combine(dstDir, name), true);
|
||||
|
||||
// 落盘 md5 命名(name_md5.ext,与 MiniGameDownloader.Md5Name 推导一致),files.txt 登记逻辑名
|
||||
private static void CopyMd5Named(string srcDir, string name, string dstDir, FilesManifest fm)
|
||||
{
|
||||
byte[] data = File.ReadAllBytes(Path.Combine(srcDir, name));
|
||||
string md5 = Md5Util.OfBytes(data);
|
||||
File.WriteAllBytes(Path.Combine(dstDir, Md5Name(name, md5)), data);
|
||||
fm.Add(name, md5, data.Length);
|
||||
}
|
||||
|
||||
// name.ext -> name_md5.ext(无扩展名则 name_md5)
|
||||
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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>disable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user