using System.IO.Pipes; using System.Security.Cryptography; using System.Text; using System.Text.Json; using XFEEDToolBox.Core.Models; namespace XFEEDToolBox.Core.Plugins; public sealed record PluginPackage { public string Id { get; init; } = ""; public string Version { get; init; } = ""; public string Loader { get; init; } = ""; public string Sha256 { get; init; } = ""; public string SupportedGameSha256 { get; init; } = ""; public bool SupportsTrade { get; init; } public bool SupportsRelog { get; init; } public bool SupportsNavigation { get; init; } } public sealed record PluginInstallation(string Id,string Version,string GameExecutable,string GameSha256,string LoaderSha256,DateTimeOffset InstalledAt,bool UninstallPending=false); public sealed record PluginStatus(bool Installed,bool PendingRemoval,string Message,PluginInstallation? Installation=null); public sealed record PluginHandshake { public int Schema { get; init; } public string Name { get; init; } = ""; public string Version { get; init; } = ""; public int ProcessId { get; init; } public bool Loaded { get; init; } public bool Active { get; init; } public bool SupportsTrade { get; init; } public bool SupportsRelog { get; init; } public bool SupportsNavigation { get; init; } public bool ObservationStarted { get; init; } } /// Own-file installation only. Existing third-party loaders are never replaced. public sealed class GamePluginManager(string packageDirectory) { public const string PluginId="XFEEDToolBox.Plugin"; private readonly SemaphoreSlim gate=new(1,1); private string PackageFile=>Path.Combine(packageDirectory,"package.json"); 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 OwnedDirectory(string game) { var path=Path.Combine(Root(game),".xfeedtoolbox-plugin"); if(Directory.Exists(path)&&(File.GetAttributes(path)&FileAttributes.ReparsePoint)!=0)throw new InvalidOperationException("插件记录目录是链接,未操作。"); return path; } private static string ManifestFile(string game)=>Path.Combine(OwnedDirectory(game),"installation.json"); public PluginPackage Package() { var p=JsonSerializer.Deserialize(File.ReadAllText(PackageFile),ToolboxSettings.JsonOptions)??throw new InvalidOperationException("插件包元数据缺失。"); if(p.Id!=PluginId||p.Loader!="dinput8.dll"||p.Sha256.Length!=64||p.SupportedGameSha256.Length!=64)throw new InvalidOperationException("插件包标识或文件清单无效。"); return p; } private static async Task Hash(string path,CancellationToken token) { await using var file=new FileStream(path,FileMode.Open,FileAccess.Read,FileShare.ReadWrite|FileShare.Delete);return Convert.ToHexStringLower(await SHA256.HashDataAsync(file,token)); } private static void SaveManifest(string path,PluginInstallation record) { var temp=path+".tmp";File.WriteAllText(temp,JsonSerializer.Serialize(record,ToolboxSettings.JsonOptions));File.Move(temp,path,true); } public async Task InspectAsync(string game,CancellationToken token=default) { var manifest=ManifestFile(game);var loader=Path.Combine(Root(game),"dinput8.dll"); if(!File.Exists(manifest))return new(false,false,File.Exists(loader)?"存在其他 dinput8.dll,安装会保留该文件并停止。":"插件未安装。"); var info=JsonSerializer.Deserialize(await File.ReadAllTextAsync(manifest,token),ToolboxSettings.JsonOptions); if(info is null||info.Id!=PluginId||!Path.GetFullPath(info.GameExecutable).Equals(Path.GetFullPath(game),StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("插件安装记录与所选游戏目录不符。"); if(!File.Exists(loader))return new(false,info.UninstallPending,"插件加载文件已移除。",info); if(!string.Equals(await Hash(loader,token),info.LoaderSha256,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("加载文件与安装时哈希不同,已保留文件,不能自动覆盖或删除。"); return new(true,info.UninstallPending,info.UninstallPending?"插件已停用,加载文件等待游戏退出后清理。":$"插件 {info.Version} 已安装;下一次游戏启动时加载。",info); } public async Task InstallAsync(string game,CancellationToken token=default) { await gate.WaitAsync(token); try { var package=Package();var source=Path.Combine(packageDirectory,package.Loader);var root=Root(game);var loader=Path.Combine(root,package.Loader); if(!File.Exists(game)||await Hash(game,token)!=package.SupportedGameSha256)throw new InvalidOperationException("所选游戏构建尚未匹配插件包;未修改游戏文件。"); if(!File.Exists(source)||await Hash(source,token)!=package.Sha256)throw new InvalidOperationException("本机插件包校验失败。"); if(File.Exists(loader)) { var existing=await InspectAsync(game,token); if(existing.Installed&&!existing.PendingRemoval) { if(existing.Installation!.LoaderSha256==package.Sha256) { if(existing.Installation.GameSha256==package.SupportedGameSha256&&existing.Installation.Version==package.Version)return existing; SaveManifest(ManifestFile(game),existing.Installation with{Version=package.Version,GameSha256=package.SupportedGameSha256}); return await InspectAsync(game,token); } return await UpdateOwnedAsync(game,loader,source,package,existing.Installation,token); } throw new InvalidOperationException("目标已有加载文件,未覆盖。请先处理原插件或完成卸载。"); } var owned=OwnedDirectory(game);Directory.CreateDirectory(owned); var manifest=ManifestFile(game);var staging=Path.Combine(owned,"loader.installing"); if(File.Exists(staging))throw new InvalidOperationException("发现未完成的安装暂存文件,已停止。"); File.Copy(source,staging,false); var moved=false; try { token.ThrowIfCancellationRequested();File.Move(staging,loader,false);moved=true; SaveManifest(manifest,new(PluginId,package.Version,Path.GetFullPath(game),package.SupportedGameSha256,package.Sha256,DateTimeOffset.UtcNow)); var disabled=Path.Combine(owned,"disabled");if(File.Exists(disabled))File.Delete(disabled); } catch { if(moved&&File.Exists(loader)&&await Hash(loader,CancellationToken.None)==package.Sha256)File.Delete(loader); if(File.Exists(staging))File.Delete(staging);throw; } return await InspectAsync(game,token); } finally{gate.Release();} } private async Task UpdateOwnedAsync(string game,string loader,string source,PluginPackage package,PluginInstallation previous,CancellationToken token) { var owned=OwnedDirectory(game);var staging=Path.Combine(owned,"loader.updating");var backup=Path.Combine(owned,"loader.previous"); if(File.Exists(staging)||File.Exists(backup))throw new InvalidOperationException("发现未完成的插件更新文件,请先检查更新记录。"); File.Copy(loader,backup,false);var replaced=false;var recovered=true; try { File.Copy(source,staging,false); for(var attempt=0;;attempt++) { token.ThrowIfCancellationRequested(); try{File.Move(staging,loader,true);replaced=true;break;} catch(Exception error)when(attempt<12&&error is IOException or UnauthorizedAccessException) { // Process exit can precede release of the mapped DLL image. // Retry only while the original owned file is unchanged. if(!File.Exists(staging)||!File.Exists(loader)||!string.Equals(await Hash(loader,token),previous.LoaderSha256,StringComparison.OrdinalIgnoreCase))throw; await Task.Delay(200,token); } } SaveManifest(ManifestFile(game),previous with{Version=package.Version,GameSha256=package.SupportedGameSha256,LoaderSha256=package.Sha256,InstalledAt=DateTimeOffset.UtcNow}); } catch(Exception error)when(error is IOException or UnauthorizedAccessException) { if(replaced) { try{File.Copy(backup,loader,true);SaveManifest(ManifestFile(game),previous);} catch(Exception recovery)when(recovery is IOException or UnauthorizedAccessException) {recovered=false;throw new InvalidOperationException("插件更新未完成,原文件备份已保留:"+backup,recovery);} } throw new InvalidOperationException("插件文件正被使用或不可写,请退出游戏后再次点击安装 / 更新。原插件已保留。",error); } finally { if(File.Exists(staging))File.Delete(staging); if(recovered&&File.Exists(backup))File.Delete(backup); } return await InspectAsync(game,token); } public async Task UninstallAsync(string game,CancellationToken token=default) { await gate.WaitAsync(token); try { var status=await InspectAsync(game,token);if(status.Installation is not { } record)return status; var owned=OwnedDirectory(game);Directory.CreateDirectory(owned);await File.WriteAllTextAsync(Path.Combine(owned,"disabled"),"disabled by user",token); var loader=Path.Combine(Root(game),"dinput8.dll"); try{if(File.Exists(loader))File.Delete(loader);} catch(Exception e)when(e is IOException or UnauthorizedAccessException) {SaveManifest(ManifestFile(game),record with{UninstallPending=true});return new(true,true,"插件已停用;游戏仍占用加载文件,退出后可完成清理。",record with{UninstallPending=true});} File.Delete(ManifestFile(game));var disabled=Path.Combine(owned,"disabled");File.Delete(disabled); if(!Directory.EnumerateFileSystemEntries(owned).Any())Directory.Delete(owned,false); return new(false,false,"本工具箱插件已卸载,原有汉化文件保留。"); } finally{gate.Release();} } public static Task ProbeAsync(int processId,bool deactivate=false,CancellationToken token=default) =>SendAsync(processId,deactivate?"deactivate\n":"status\n",token); public static Task StartNavigationObservationAsync(int processId,CancellationToken token=default) =>SendAsync(processId,"navigation.observe.start\n",token); private static async Task SendAsync(int processId,string command,CancellationToken token) { using var timeout=CancellationTokenSource.CreateLinkedTokenSource(token);timeout.CancelAfter(TimeSpan.FromSeconds(2)); for(var attempt=0;;attempt++) { try { using var pipe=new NamedPipeClientStream(".",$"XFEEDToolBox.Plugin.{processId}",PipeDirection.InOut,PipeOptions.Asynchronous); await pipe.ConnectAsync(timeout.Token); await pipe.WriteAsync(Encoding.UTF8.GetBytes(command),timeout.Token);await pipe.FlushAsync(timeout.Token); var buffer=new byte[4096];var count=0; while(count=buffer.Length)throw new InvalidOperationException("插件握手响应超出限制。"); var reply=JsonSerializer.Deserialize(buffer.AsSpan(0,count),ToolboxSettings.JsonOptions)??throw new InvalidOperationException("插件握手无效。"); if(reply.Schema!=1||reply.Name!=PluginId||reply.ProcessId!=processId)throw new InvalidOperationException("插件身份与选定游戏进程不符。"); return reply; } catch(Exception e)when(attempt<2&&(e is IOException or JsonException)){await Task.Delay(40,timeout.Token);} } } }