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);}
}
}
}