XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
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; }
}

/// <summary>Own-file installation only. Existing third-party loaders are never replaced.</summary>
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<PluginPackage>(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<string> 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<PluginStatus> 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<PluginInstallation>(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<PluginStatus> 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<PluginStatus> 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<PluginStatus> 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<PluginHandshake> ProbeAsync(int processId,bool deactivate=false,CancellationToken token=default)
        =>SendAsync(processId,deactivate?"deactivate\n":"status\n",token);
    public static Task<PluginHandshake> StartNavigationObservationAsync(int processId,CancellationToken token=default)
        =>SendAsync(processId,"navigation.observe.start\n",token);
    private static async Task<PluginHandshake> 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)
                {var read=await pipe.ReadAsync(buffer.AsMemory(count,buffer.Length-count),timeout.Token);if(read==0)break;count+=read;if(buffer[count-1]=='\n')break;}
                if(count==0)throw new EndOfStreamException("插件在回复读取前关闭连接;请在下次游戏退出后更新通信插件。");
                if(count>=buffer.Length)throw new InvalidOperationException("插件握手响应超出限制。");
                var reply=JsonSerializer.Deserialize<PluginHandshake>(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);}
        }
    }
}