using System.Security.Cryptography; using System.Text.Json; using XFEEDToolBox.Core.Models; namespace XFEEDToolBox.Core.Plugins; public sealed record TranslationBundle(string GameSha256,IReadOnlyDictionary Files) { public static TranslationBundle Reference { get; } = new("6891f70a021fa9f3a0e00458c284f48d1226e68ae7fc02ad48081aed47857e93",new Dictionary { ["dbghelp.dll"]="9e0c0ab1d854521918e59800496ff56d9bf97b436ad145f1ee88ffb33ea721fa", ["edchs.pkg"]="6d8256802b37041a9c819655f1cecd9432b6452171e794dba2d5a08313f1e898" }); } public sealed record TranslationStatus(bool Installed,bool Owned,string Message); public sealed record TranslationInstallation(string Id,string GameExecutable,Dictionary Files,DateTimeOffset InstalledAt); /// Imports the user's local, verified translation bundle. Does not redistribute or decrypt it. public sealed class ChineseTranslationManager(TranslationBundle? trusted=null) { private readonly TranslationBundle bundle=trusted??TranslationBundle.Reference; private readonly SemaphoreSlim gate=new(1,1); private const string Id="XFEEDToolBox.Chinese"; private static string Root(string game) { var path=Path.GetFullPath(game); if(!Path.GetFileName(path).Equals("EliteDangerous64.exe",StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("请选择游戏主程序 EliteDangerous64.exe。"); return Path.GetDirectoryName(path)!; } private static string Owned(string game) { var path=Path.Combine(Root(game),".xfeedtoolbox-chinese"); if(Directory.Exists(path)&&(File.GetAttributes(path)&FileAttributes.ReparsePoint)!=0)throw new InvalidOperationException("汉化记录目录是链接,未操作。"); return path; } private static async Task Hash(string file,CancellationToken token) {await using var stream=new FileStream(file,FileMode.Open,FileAccess.Read,FileShare.Read);return Convert.ToHexStringLower(await SHA256.HashDataAsync(stream,token));} public async Task InspectAsync(string game,CancellationToken token=default) { var root=Root(game);var manifest=Path.Combine(Owned(game),"installation.json");var owned=File.Exists(manifest); TranslationInstallation? record=null; if(owned) { record=JsonSerializer.Deserialize(await File.ReadAllTextAsync(manifest,token),ToolboxSettings.JsonOptions); if(record is null||record.Id!=Id||!record.GameExecutable.Equals(Path.GetFullPath(game),StringComparison.OrdinalIgnoreCase)||record.Files.Count!=bundle.Files.Count||record.Files.Any(p=>!bundle.Files.TryGetValue(p.Key,out var hash)||hash!=p.Value))throw new InvalidOperationException("汉化安装记录不匹配,未修改文件。"); } foreach(var (name,hash) in bundle.Files) { var file=Path.Combine(root,name); if(!File.Exists(file))return new(false,owned,owned?"汉化文件不完整,可还原本工具安装。":"未检测到完整汉化组件。"); if(await Hash(file,token)!=hash)return new(false,owned,"存在其他版本或被改动的汉化组件,已保留文件。"); } return new(true,owned,owned?"简体中文汉化已安装。":"已有兼容的简体中文组件,无需重复安装。"); } // Copy the complete verified bundle into the app's own resource store. Neither its source // directory nor an external executable is needed for later installs from this store. public async Task ImportBundleAsync(string source,string resourceStore,CancellationToken token=default) { await gate.WaitAsync(token); string? staging=null; try { foreach(var(name,hash) in bundle.Files) if(!File.Exists(Path.Combine(source,name))||await Hash(Path.Combine(source,name),token)!=hash)throw new InvalidOperationException("汉化资源缺失或校验不符:"+name); var destination=Path.Combine(Path.GetFullPath(resourceStore),bundle.GameSha256); if(Directory.Exists(destination)) { foreach(var(name,hash) in bundle.Files) if(!File.Exists(Path.Combine(destination,name))||await Hash(Path.Combine(destination,name),token)!=hash)throw new InvalidOperationException("本地汉化资源缓存不完整,请重新导入到新的资源目录。"); return destination; } Directory.CreateDirectory(Path.GetFullPath(resourceStore));staging=destination+"."+Guid.NewGuid().ToString("N")+".importing";Directory.CreateDirectory(staging); foreach(var(name,hash) in bundle.Files) { var file=Path.Combine(staging,name); await using(var input=File.OpenRead(Path.Combine(source,name)))await using(var output=new FileStream(file,FileMode.CreateNew,FileAccess.Write,FileShare.None))await input.CopyToAsync(output,token); if(await Hash(file,token)!=hash)throw new IOException("复制期间汉化资源发生变化,导入已停止。"); } Directory.Move(staging,destination);staging=null;return destination; } finally { try{if(staging is not null&&Directory.Exists(staging)){foreach(var file in Directory.EnumerateFiles(staging))File.Delete(file);Directory.Delete(staging,false);}} finally{gate.Release();} } } public async Task InstallAsync(string game,string source,CancellationToken token=default) { await gate.WaitAsync(token); try { var state=await InspectAsync(game,token);if(state.Installed)return state; if(!File.Exists(game)||await Hash(game,token)!=bundle.GameSha256)throw new InvalidOperationException("该游戏构建尚未匹配汉化包,未安装。"); foreach(var (name,hash) in bundle.Files) { var file=Path.Combine(source,name); if(!File.Exists(file)||await Hash(file,token)!=hash)throw new InvalidOperationException("本地汉化源缺失或校验不符:"+name); if(File.Exists(Path.Combine(Root(game),name)))throw new InvalidOperationException("目标已有同名组件,保留原文件并停止安装:"+name); } var owned=Owned(game);Directory.CreateDirectory(owned);var moved=new List();var staged=new List(); if(bundle.Files.Keys.Any(name=>File.Exists(Path.Combine(owned,name+".installing"))))throw new InvalidOperationException("存在未完成的汉化暂存文件,已保留并停止。"); try { foreach(var (name,hash) in bundle.Files) { var staging=Path.Combine(owned,name+".installing"); await using(var input=File.OpenRead(Path.Combine(source,name)))await using(var output=new FileStream(staging,FileMode.CreateNew,FileAccess.Write,FileShare.None)){staged.Add(name);await input.CopyToAsync(output,token);} if(await Hash(staging,token)!=hash)throw new IOException("复制期间源文件变化,安装已停止。"); } foreach(var name in bundle.Files.Keys){token.ThrowIfCancellationRequested();File.Move(Path.Combine(owned,name+".installing"),Path.Combine(Root(game),name),false);moved.Add(name);} var record=new TranslationInstallation(Id,Path.GetFullPath(game),new(bundle.Files),DateTimeOffset.UtcNow); var manifest=Path.Combine(owned,"installation.json");await File.WriteAllTextAsync(manifest+".tmp",JsonSerializer.Serialize(record,ToolboxSettings.JsonOptions),token);File.Move(manifest+".tmp",manifest,true); } catch { foreach(var name in moved){var file=Path.Combine(Root(game),name);if(File.Exists(file)&&await Hash(file,CancellationToken.None)==bundle.Files[name])File.Delete(file);} throw; } finally{foreach(var name in staged){var file=Path.Combine(owned,name+".installing");if(File.Exists(file))File.Delete(file);}} return await InspectAsync(game,token); } finally{gate.Release();} } public async Task UninstallAsync(string game,CancellationToken token=default) { await gate.WaitAsync(token); try { var state=await InspectAsync(game,token); if(!state.Owned)throw new InvalidOperationException("当前兼容组件不属于本工具的安装记录,已保留现有文件。"); foreach(var (name,hash) in bundle.Files){var file=Path.Combine(Root(game),name);if(File.Exists(file)&&await Hash(file,token)!=hash)throw new InvalidOperationException("组件已被改动,保留文件:"+name);} // Test access for the entire bundle before removing either component. var locks=new List(); try{foreach(var name in bundle.Files.Keys){var file=Path.Combine(Root(game),name);if(File.Exists(file))locks.Add(new(file,FileMode.Open,FileAccess.Read,FileShare.None));}} finally{foreach(var held in locks)held.Dispose();} foreach(var name in bundle.Files.Keys){var file=Path.Combine(Root(game),name);if(File.Exists(file))File.Delete(file);} var owned=Owned(game);File.Delete(Path.Combine(owned,"installation.json"));if(!Directory.EnumerateFileSystemEntries(owned).Any())Directory.Delete(owned,false); return new(false,false,"已还原本工具安装的简体中文组件;游戏 EXE 与贸易插件保持原样。"); } finally{gate.Release();} } }